Compare commits
No commits in common. "main" and "ccra-agent-skills-2026-06-19" have entirely different histories.
main
...
ccra-agent
|
|
@ -28,7 +28,6 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regr
|
|||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation
|
||||
```
|
||||
|
||||
Use `-Force` to back up and replace an existing installed copy:
|
||||
|
|
@ -41,7 +40,6 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regr
|
|||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation -Force
|
||||
```
|
||||
|
||||
`-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly.
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ Existing CCPE content is not migrated here.
|
|||
| Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `bundle-zip` | Create zip archives from explicit file lists while preserving source-relative paths and validating entries by readback. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\bundle-zip` | `installed` | `default` | `C:\Users\wangq\.agents\skills\bundle-zip` | none |
|
||||
| `fix-title` | Agentic semantic repair for copied GPT/LLM Markdown heading hierarchy across one or more discussion or artifact files, with fixed copies, heading plans, and reports. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
|
||||
| `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
|
||||
| `lifecycle-status-guard-scan` | Scan configured Markdown, JSON, YAML, and text files for lifecycle/status overclaim candidates and missing local evidence markers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\lifecycle-status-guard-scan` | `installed` | `default` | `C:\Users\wangq\.agents\skills\lifecycle-status-guard-scan` | none |
|
||||
| `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none |
|
||||
| `regression-validation-gate-runner` | Dry-run or execute project-declared regression and validation gates from a manifest while capturing logs, exit codes, durations, skipped gates, and changed-file notes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\regression-validation-gate-runner` | `installed` | `default` | `C:\Users\wangq\.agents\skills\regression-validation-gate-runner` | none |
|
||||
| `review-bundle-audit` | Preflight review, handoff, release, CCRA, Web upload, or code-review bundle directories for required files, manifests, sidecars, reports, zip readability, warnings, and blockers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-bundle-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-bundle-audit` | none |
|
||||
| `review-context-builder` | Build file-first review, audit, planning, release, handoff, PR, or Agent invocation context indexes and manifests from configured local source roots, patterns, and metadata. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-context-builder` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-context-builder` | none |
|
||||
| `routing-behavior-diff-audit` | Compare before/after selector, classifier, or routing outputs for targeted changes, collateral non-target changes, no-call changes, missing cases, and expected-route mismatch changes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\routing-behavior-diff-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\routing-behavior-diff-audit` | none |
|
||||
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\voice-generation` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
|
||||
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
|
||||
|
||||
## Status Values
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Quick validation for a Codex skill directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
MAX_SKILL_NAME_LENGTH = 64
|
||||
|
||||
|
||||
def validate_skill(skill_path: Path) -> tuple[bool, str]:
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found"
|
||||
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
if not content.startswith("---"):
|
||||
return False, "No YAML frontmatter found"
|
||||
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format"
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError as exc:
|
||||
return False, f"Invalid YAML in frontmatter: {exc}"
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
return False, "Frontmatter must be a YAML dictionary"
|
||||
|
||||
allowed_keys = {"name", "description"}
|
||||
unexpected_keys = set(frontmatter) - allowed_keys
|
||||
if unexpected_keys:
|
||||
return False, "Unexpected frontmatter key(s): " + ", ".join(
|
||||
sorted(unexpected_keys)
|
||||
)
|
||||
|
||||
name = frontmatter.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return False, "Missing or invalid 'name' in frontmatter"
|
||||
if not re.fullmatch(r"[a-z0-9-]+", name):
|
||||
return False, "Name must use lowercase letters, digits, and hyphens only"
|
||||
if name.startswith("-") or name.endswith("-") or "--" in name:
|
||||
return False, "Name cannot start/end with hyphen or contain consecutive hyphens"
|
||||
if len(name) > MAX_SKILL_NAME_LENGTH:
|
||||
return False, f"Name exceeds {MAX_SKILL_NAME_LENGTH} characters"
|
||||
|
||||
description = frontmatter.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
return False, "Missing or invalid 'description' in frontmatter"
|
||||
if "<" in description or ">" in description:
|
||||
return False, "Description cannot contain angle brackets"
|
||||
if len(description) > 1024:
|
||||
return False, "Description exceeds 1024 characters"
|
||||
|
||||
return True, "Skill is valid!"
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 1:
|
||||
print("Usage: python scripts/quick_validate.py <skill-directory>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
valid, message = validate_skill(Path(argv[0]))
|
||||
print(message)
|
||||
return 0 if valid else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -20,7 +20,7 @@ conda activate skills-vault
|
|||
## Test
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
|
||||
python -m unittest discover -s skills\fix-title\tests -v
|
||||
```
|
||||
|
||||
## Install To Public Skill Surface
|
||||
|
|
|
|||
|
|
@ -1,199 +1,52 @@
|
|||
---
|
||||
name: fix-title
|
||||
description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: default to lightweight in-place repair that overwrites the source Markdown without sidecar files; use audit mode only when the caller asks for preserved originals, fixed copies, heading maps, JSON plans, or reports."
|
||||
description: Use when a pasted ChatGPT or LLM Markdown reply has heading levels that need to be shifted down before insertion under a parent section such as "## GPT"; also use for Markdown title repair, heading nesting fixes, ATX header adjustment, and adding one or more # levels to headings in a file.
|
||||
---
|
||||
|
||||
# Fix Title
|
||||
|
||||
## Agentic Input
|
||||
## Overview
|
||||
|
||||
Use this skill from an agent prompt, not as a Python CLI.
|
||||
Fix Markdown heading depth for copied LLM replies before inserting them into a discussion record. Use the bundled script for deterministic file edits instead of asking the model to rewrite the document.
|
||||
|
||||
Required inputs:
|
||||
The default operation adds two heading levels, turning `# Title` into `### Title`, which fits content placed under a parent heading like `## GPT`.
|
||||
|
||||
- `files`: one or more Markdown file paths.
|
||||
- `mode`: `discussion` or `artifact`.
|
||||
## Workflow
|
||||
|
||||
Optional inputs:
|
||||
1. Confirm the input is a Markdown file containing the raw reply to adjust.
|
||||
2. Choose the number of levels to add. Default to `2` unless the user specifies another value.
|
||||
3. Run `scripts/fix_markdown_titles.py` on the file.
|
||||
4. If the target is important or the requested level shift is unusual, use `--dry-run` or `--output` first and inspect the result.
|
||||
5. Report the path modified and the level shift used.
|
||||
|
||||
- `output`: `quick` or `audit`. Default to `quick`.
|
||||
- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `inline` for quick single-file work and `auto` for audit or large batches.
|
||||
- `output_dir`: audit output folder. Ignore in quick mode unless the caller also asks for backup or reports.
|
||||
- `backup`: `never`, `always`, or `auto`. Default to `never`; rely on git unless the caller asks for backups or the file is outside version control.
|
||||
## Commands
|
||||
|
||||
Example user-facing request:
|
||||
|
||||
```text
|
||||
Use $fix-title with mode=artifact on C:\path\one.md
|
||||
```bash
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md --levels 1
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md --levels 3 --dry-run
|
||||
python scripts/fix_markdown_titles.py path/to/reply.md --levels 2 --output path/to/fixed.md
|
||||
```
|
||||
|
||||
This defaults to quick mode and overwrites `C:\path\one.md` directly.
|
||||
|
||||
Audit example:
|
||||
|
||||
```text
|
||||
Use $fix-title with mode=artifact and output=audit on:
|
||||
- C:\path\one.md
|
||||
- C:\path\two.md
|
||||
Output to C:\path\tmp\fix-title-output
|
||||
```
|
||||
|
||||
## Output Modes
|
||||
|
||||
### Quick
|
||||
|
||||
Use quick mode by default.
|
||||
|
||||
- Overwrite each source Markdown file in place.
|
||||
- Do not write `heading-map.json`, `heading-plan.json`, `.fixed.md`, heading reports, or batch reports.
|
||||
- Use in-memory edits and `scripts/semantic_heading_repair.py` helpers when practical.
|
||||
- Return a concise summary with changed heading counts and skipped files.
|
||||
- If a file is ambiguous, skip it and tell the caller why instead of creating audit artifacts by default.
|
||||
|
||||
Quick mode is for routine cleanup where the caller wants the document fixed, not a review package.
|
||||
|
||||
### Audit
|
||||
|
||||
Use audit mode only when the caller asks for auditability, preserved originals, fixed copies, maps, plans, reports, review evidence, batch reports, or no overwrite.
|
||||
|
||||
For each source file, write:
|
||||
|
||||
```text
|
||||
<stem>.heading-map.json
|
||||
<stem>.heading-plan.json
|
||||
<stem>.fixed.md
|
||||
<stem>.heading-report.md
|
||||
```
|
||||
|
||||
For batch runs, also write:
|
||||
|
||||
```text
|
||||
fix-title-batch-report.md
|
||||
```
|
||||
|
||||
The plan JSON is the audit boundary. Every changed heading must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"line": 25,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "This is a top-level section inside the GPT reply block under ## GPT."
|
||||
}
|
||||
```
|
||||
|
||||
The caller must verify returned files and reports in audit mode. Do not treat a subagent/thread message alone as proof of repair.
|
||||
|
||||
## Execution Isolation
|
||||
|
||||
Quick single-file repairs should usually run inline. Prefer isolation only when semantic repair consumes significant context or the caller asks for it.
|
||||
|
||||
- `inline`: Use for quick mode and small files.
|
||||
- `subagent`: Use when the host supports delegated agents and the batch is large or audit mode is requested.
|
||||
- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session execution.
|
||||
- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` for audit or large batches when available, otherwise `inline`.
|
||||
|
||||
## Delegation Prompt
|
||||
|
||||
For quick mode, send this shape to the isolated worker only when isolation is warranted:
|
||||
|
||||
```text
|
||||
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
|
||||
|
||||
Mode: <discussion|artifact>
|
||||
Output: quick
|
||||
Files:
|
||||
- <absolute-path-1>
|
||||
- <absolute-path-2>
|
||||
|
||||
Rules:
|
||||
- Read and write source files as UTF-8.
|
||||
- Overwrite each source Markdown file in place.
|
||||
- Do not write heading-map, heading-plan, fixed-copy, heading-report, or batch-report files.
|
||||
- Preserve non-heading content.
|
||||
- Apply only deliberate heading-level edits.
|
||||
- Flag ambiguous appended artifacts or uncertain heading relationships instead of silently making creative decisions.
|
||||
- Return changed heading counts and skipped files.
|
||||
```
|
||||
|
||||
For audit mode, send this shape:
|
||||
|
||||
```text
|
||||
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
|
||||
|
||||
Mode: <discussion|artifact>
|
||||
Output: audit
|
||||
Output directory: <output-dir>
|
||||
Files:
|
||||
- <absolute-path-1>
|
||||
- <absolute-path-2>
|
||||
|
||||
Rules:
|
||||
- Read source files as UTF-8.
|
||||
- Do not overwrite originals.
|
||||
- Preserve non-heading content.
|
||||
- Build heading maps, write explicit JSON heading plans, apply only planned heading edits, and write reports.
|
||||
- Return the fixed file, heading-map, heading-plan, and report paths for every input file.
|
||||
- Flag ambiguous appended artifacts or uncertain heading relationships instead of silently making creative decisions.
|
||||
```
|
||||
|
||||
For Codex specifically, use `spawn_agent` for subagent isolation. Use a Codex Thread only when the user requests thread/sub-session execution or host policy permits it for the current task.
|
||||
|
||||
## Modes
|
||||
|
||||
### Discussion
|
||||
|
||||
Use when a file records conversation rounds, such as `# 1`, `# 2`, `## 任务`, `## 指令`, `## GPT`, or `## GPT的回复`.
|
||||
|
||||
- Preserve outer record structure.
|
||||
- Treat each GPT reply block as a child of its marker heading.
|
||||
- Repair headings inside the GPT reply so top-level GPT content starts one level deeper than the GPT marker, usually `###`.
|
||||
- Repair descendants recursively by semantic relationship, not by raw hash count.
|
||||
|
||||
Example: under `## GPT`, raw `# 1.xxxx`, raw `# 2.xxx`, and raw `## 3.xxx` may all become `###` when they are peer sections in the GPT reply.
|
||||
|
||||
### Artifact
|
||||
|
||||
Use when copied GPT output is stored as a standalone planning/intake/artifact document.
|
||||
|
||||
- Treat the first visible `#` heading as the document title by default.
|
||||
- Repair later accidental top-level headings under that root.
|
||||
- Preserve one clear document title.
|
||||
- If a later heading is actually an appended second artifact, do not silently merge it; mark it in the report for human review.
|
||||
|
||||
Example: after a root title, raw sibling headings `# 1.xxxx`, `# 2.xxx`, and `## 3.xxx` may all become `##` when they are peer sections.
|
||||
|
||||
## Internal Helper
|
||||
|
||||
Use `scripts/semantic_heading_repair.py` only as an internal importable helper. It is not a user-facing CLI.
|
||||
|
||||
Useful helper API:
|
||||
|
||||
- `inspect_payload(path)`
|
||||
- `write_inspection(source, output, output_format="json")`
|
||||
- `prepare_batch_scaffold(files, mode, output_dir)`
|
||||
- `apply_edits(source, edits, output, report=None, mode="quick")`
|
||||
- `apply_edits_in_place(source, edits, mode="quick", report=None)`
|
||||
- `apply_plan(source, plan, output, report)`
|
||||
|
||||
The helper protects fenced code blocks, quoted headings, and indented code. It does not make semantic decisions.
|
||||
|
||||
## Repair Rules
|
||||
|
||||
- Quick mode overwrites the source file by default.
|
||||
- Audit mode never overwrites the source file unless the caller explicitly asks for replacement after reviewing the fixed copy.
|
||||
- Do not mechanically add or subtract the same number of heading levels across a whole file.
|
||||
- Do not change non-heading content.
|
||||
- Do not change headings inside fenced code blocks, block quotes, or indented code.
|
||||
- Do not treat Markdown numbering as decisive. `## 3.xxx` can be a sibling of `# 1.xxx` when context shows they are peers.
|
||||
- When a parent heading is repaired, re-evaluate descendants recursively.
|
||||
- If unsure, preserve content and record the ambiguity in the report.
|
||||
|
||||
## Validation
|
||||
|
||||
Run after editing this skill:
|
||||
On Windows PowerShell, quote paths that contain spaces:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
|
||||
conda run -n skills-vault python -B scripts\quick_validate.py skills\fix-title
|
||||
python .\scripts\fix_markdown_titles.py "C:\path with spaces\reply.md" --levels 2
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
| Input | `--levels 2` output |
|
||||
| --- | --- |
|
||||
| `# A` | `### A` |
|
||||
| `## B` | `#### B` |
|
||||
| `###### C` | `###### C` |
|
||||
|
||||
The script only changes ATX headings: lines beginning with up to three spaces followed by `#` through `######` and then whitespace. It does not change headings inside fenced code blocks, quoted lines such as `> # text`, or indented code.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Do not use a broad replace like `#` to `###`; it will corrupt code blocks, quoted content, tags, and inline text.
|
||||
- Do not increase headings past level six; Markdown ATX headings stop at `######`.
|
||||
- Do not change the recorder's outer headings such as `# 1` or `## GPT` unless the user explicitly points the skill at the recorder file and requests that behavior.
|
||||
- Do not use prompt rewriting for this task when the script can perform the edit directly.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
display_name: "Fix Title"
|
||||
short_description: "Lightweight repair for Markdown heading trees."
|
||||
default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy in place. Ask for output=audit when fixed copies, heading maps, JSON plans, or reports are needed."
|
||||
display_name: Fix Markdown Titles
|
||||
short_description: Shift Markdown headings down before inserting pasted chat replies.
|
||||
default_prompt: Fix the Markdown heading levels in this file so it can be pasted under a parent section.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
# Manual Test
|
||||
# Manual Test Input
|
||||
|
||||
Use this directory for small manual validation files when needed.
|
||||
Place one raw Markdown file here for manual validation.
|
||||
|
||||
Recommended files:
|
||||
Recommended filename:
|
||||
|
||||
```text
|
||||
raw-discussion.md
|
||||
raw-artifact.md
|
||||
audit-heading-plan.json
|
||||
raw-reply.md
|
||||
```
|
||||
|
||||
The raw files should contain disposable copies of original LLM/GPT Markdown before heading repair.
|
||||
The file should be an original pasted LLM reply before heading repair.
|
||||
|
||||
Default manual validation should invoke the Skill with `files=[...]` and `mode=discussion|artifact`, then confirm the source copy was repaired in place without sidecar files.
|
||||
|
||||
Use `output=audit` and an explicit `output_dir` only when validating fixed-copy, heading-map, heading-plan, and report generation.
|
||||
After the file is added, run the Skill against a copy or with `--output` first, then compare the result before updating the installed version.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Shift Markdown ATX headings down by a fixed number of levels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+.*)$")
|
||||
FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})")
|
||||
|
||||
|
||||
def shift_headings(text: str, levels: int) -> str:
|
||||
if levels <= 0:
|
||||
raise ValueError("levels must be a positive integer")
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
output: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
body = line[:-1] if line.endswith("\n") else line
|
||||
newline = "\n" if line.endswith("\n") else ""
|
||||
if body.endswith("\r"):
|
||||
body = body[:-1]
|
||||
newline = "\r" + newline
|
||||
|
||||
fence_match = FENCE_RE.match(body)
|
||||
if fence_match:
|
||||
marker = fence_match.group(2)
|
||||
marker_char = marker[0]
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_marker = marker_char * len(marker)
|
||||
elif marker_char == fence_marker[0] and len(marker) >= len(fence_marker):
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
output.append(line)
|
||||
continue
|
||||
|
||||
if in_fence:
|
||||
output.append(line)
|
||||
continue
|
||||
|
||||
heading_match = HEADING_RE.match(body)
|
||||
if heading_match:
|
||||
indent, hashes, rest = heading_match.groups()
|
||||
new_level = min(6, len(hashes) + levels)
|
||||
output.append(f"{indent}{'#' * new_level}{rest}{newline}")
|
||||
else:
|
||||
output.append(line)
|
||||
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Shift Markdown ATX headings down by adding # characters."
|
||||
)
|
||||
parser.add_argument("path", type=pathlib.Path, help="Markdown file to rewrite")
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--levels",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Number of heading levels to add. Default: 2",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
help="Write to this file instead of modifying the source file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print converted content to stdout without writing files",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
if args.levels <= 0:
|
||||
print("error: --levels must be a positive integer", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
source = args.path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"error: cannot read {args.path}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
fixed = shift_headings(source, args.levels)
|
||||
|
||||
if args.dry_run:
|
||||
sys.stdout.write(fixed)
|
||||
return 0
|
||||
|
||||
destination = args.output or args.path
|
||||
try:
|
||||
destination.write_text(fixed, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"error: cannot write {destination}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Internal helpers for semantic Markdown heading repair plans.
|
||||
|
||||
The semantic decision belongs to the agent using this skill. This script keeps
|
||||
the file rewrite narrow: it only changes ATX heading marker depth at explicit
|
||||
line numbers from a reviewed plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+)(.*)$")
|
||||
FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Heading:
|
||||
line: int
|
||||
level: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Edit:
|
||||
line: int
|
||||
from_level: int | None
|
||||
to_level: int
|
||||
reason: str
|
||||
|
||||
|
||||
def _split_body_newline(line: str) -> tuple[str, str]:
|
||||
if line.endswith("\r\n"):
|
||||
return line[:-2], "\r\n"
|
||||
if line.endswith("\n"):
|
||||
return line[:-1], "\n"
|
||||
if line.endswith("\r"):
|
||||
return line[:-1], "\r"
|
||||
return line, ""
|
||||
|
||||
|
||||
def _clean_heading_text(raw: str) -> str:
|
||||
text = raw.strip()
|
||||
closing = re.match(r"^(.*?)(?:[ \t]+#{1,}[ \t]*)$", text)
|
||||
if closing:
|
||||
text = closing.group(1).rstrip()
|
||||
return text
|
||||
|
||||
|
||||
def iter_headings(lines: list[str]) -> list[Heading]:
|
||||
headings: list[Heading] = []
|
||||
in_fence = False
|
||||
fence_char = ""
|
||||
fence_len = 0
|
||||
|
||||
for index, line in enumerate(lines, start=1):
|
||||
body, _newline = _split_body_newline(line)
|
||||
fence_match = FENCE_RE.match(body)
|
||||
if fence_match:
|
||||
marker = fence_match.group(2)
|
||||
marker_char = marker[0]
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_char = marker_char
|
||||
fence_len = len(marker)
|
||||
elif marker_char == fence_char and len(marker) >= fence_len:
|
||||
in_fence = False
|
||||
fence_char = ""
|
||||
fence_len = 0
|
||||
continue
|
||||
|
||||
if in_fence:
|
||||
continue
|
||||
|
||||
heading_match = HEADING_RE.match(body)
|
||||
if not heading_match:
|
||||
continue
|
||||
|
||||
hashes = heading_match.group(2)
|
||||
text = _clean_heading_text(heading_match.group(4))
|
||||
headings.append(Heading(line=index, level=len(hashes), text=text))
|
||||
|
||||
return headings
|
||||
|
||||
|
||||
def inspect_payload(path: pathlib.Path) -> dict[str, Any]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
return {
|
||||
"source": str(path),
|
||||
"heading_count": len(iter_headings(lines)),
|
||||
"headings": [
|
||||
{"line": item.line, "level": item.level, "text": item.text}
|
||||
for item in iter_headings(lines)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_inspection(payload: dict[str, Any], output_format: str) -> str:
|
||||
if output_format == "json":
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
if output_format == "markdown":
|
||||
return format_markdown_inspection(payload)
|
||||
raise ValueError("output_format must be 'json' or 'markdown'")
|
||||
|
||||
|
||||
def write_inspection(
|
||||
source: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
output_format: str = "markdown",
|
||||
) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
render_inspection(inspect_payload(source), output_format),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def format_markdown_inspection(payload: dict[str, Any]) -> str:
|
||||
output = [
|
||||
"# Markdown Heading Inspection",
|
||||
"",
|
||||
f"- Source: `{payload['source']}`",
|
||||
f"- Heading count: {payload['heading_count']}",
|
||||
"",
|
||||
"| Line | Level | Text |",
|
||||
"| --- | --- | --- |",
|
||||
]
|
||||
for heading in payload["headings"]:
|
||||
output.append(
|
||||
f"| {heading['line']} | {heading['level']} | {heading['text']} |"
|
||||
)
|
||||
output.append("")
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
def _unique_stem(path: pathlib.Path, seen: dict[str, int]) -> str:
|
||||
base = path.stem
|
||||
count = seen.get(base, 0) + 1
|
||||
seen[base] = count
|
||||
if count == 1:
|
||||
return base
|
||||
return f"{base}-{count}"
|
||||
|
||||
|
||||
def _batch_item_paths(
|
||||
source: pathlib.Path,
|
||||
mode: str,
|
||||
output_dir: pathlib.Path,
|
||||
stem: str,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"source": str(source),
|
||||
"mode": mode,
|
||||
"heading_map": str(output_dir / f"{stem}.heading-map.json"),
|
||||
"plan": str(output_dir / f"{stem}.heading-plan.json"),
|
||||
"fixed": str(output_dir / f"{stem}.fixed.md"),
|
||||
"report": str(output_dir / f"{stem}.heading-report.md"),
|
||||
}
|
||||
|
||||
|
||||
def prepare_batch_scaffold(
|
||||
files: list[pathlib.Path],
|
||||
mode: str,
|
||||
output_dir: pathlib.Path,
|
||||
) -> list[dict[str, str]]:
|
||||
if mode not in {"discussion", "artifact"}:
|
||||
raise ValueError("mode must be 'discussion' or 'artifact'")
|
||||
if not files:
|
||||
raise ValueError("files must contain at least one Markdown path")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
seen: dict[str, int] = {}
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
for source in files:
|
||||
source = pathlib.Path(source)
|
||||
if not source.exists():
|
||||
raise ValueError(f"source file does not exist: {source}")
|
||||
stem = _unique_stem(source, seen)
|
||||
item = _batch_item_paths(source, mode, output_dir, stem)
|
||||
write_inspection(source, pathlib.Path(item["heading_map"]), "json")
|
||||
pathlib.Path(item["plan"]).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mode": mode,
|
||||
"source": item["source"],
|
||||
"output": item["fixed"],
|
||||
"report": item["report"],
|
||||
"edits": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
write_batch_report(items, output_dir / "fix-title-batch-report.md")
|
||||
return items
|
||||
|
||||
|
||||
def write_batch_report(items: list[dict[str, str]], output: pathlib.Path) -> None:
|
||||
lines = [
|
||||
"# Fix Title Batch Report",
|
||||
"",
|
||||
"| Source | Mode | Heading Map | Plan | Fixed | Report |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for item in items:
|
||||
lines.append(
|
||||
"| {source} | {mode} | {heading_map} | {plan} | {fixed} | {report} |".format(
|
||||
**item
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
output.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def parse_plan(plan_path: pathlib.Path) -> tuple[str, list[Edit]]:
|
||||
try:
|
||||
payload = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"invalid JSON plan {plan_path}: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("plan must be a JSON object")
|
||||
|
||||
mode = str(payload.get("mode", "unspecified"))
|
||||
raw_edits = payload.get("edits")
|
||||
if not isinstance(raw_edits, list):
|
||||
raise ValueError("plan must contain an edits list")
|
||||
|
||||
edits: list[Edit] = []
|
||||
seen_lines: set[int] = set()
|
||||
for index, raw_edit in enumerate(raw_edits, start=1):
|
||||
if not isinstance(raw_edit, dict):
|
||||
raise ValueError(f"edit {index} must be an object")
|
||||
line = raw_edit.get("line")
|
||||
from_level = raw_edit.get("from_level")
|
||||
to_level = raw_edit.get("to_level")
|
||||
reason = raw_edit.get("reason", "")
|
||||
|
||||
if not isinstance(line, int) or line <= 0:
|
||||
raise ValueError(f"edit {index} has invalid line")
|
||||
if line in seen_lines:
|
||||
raise ValueError(f"duplicate edit for line {line}")
|
||||
seen_lines.add(line)
|
||||
|
||||
if from_level is not None and (
|
||||
not isinstance(from_level, int) or not 1 <= from_level <= 6
|
||||
):
|
||||
raise ValueError(f"edit {index} has invalid from_level")
|
||||
if not isinstance(to_level, int) or not 1 <= to_level <= 6:
|
||||
raise ValueError(f"edit {index} has invalid to_level")
|
||||
if not isinstance(reason, str):
|
||||
raise ValueError(f"edit {index} has invalid reason")
|
||||
|
||||
edits.append(
|
||||
Edit(
|
||||
line=line,
|
||||
from_level=from_level,
|
||||
to_level=to_level,
|
||||
reason=reason.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
return mode, edits
|
||||
|
||||
|
||||
def _apply_edits_to_lines(
|
||||
lines: list[str],
|
||||
edits: list[Edit],
|
||||
) -> list[dict[str, Any]]:
|
||||
headings_by_line = {heading.line: heading for heading in iter_headings(lines)}
|
||||
changed: list[dict[str, Any]] = []
|
||||
|
||||
for edit in edits:
|
||||
heading = headings_by_line.get(edit.line)
|
||||
if heading is None:
|
||||
raise ValueError(f"line {edit.line} is not an editable ATX heading")
|
||||
if edit.from_level is not None and heading.level != edit.from_level:
|
||||
raise ValueError(
|
||||
f"line {edit.line} expected level {edit.from_level}, "
|
||||
f"found level {heading.level}"
|
||||
)
|
||||
|
||||
body, newline = _split_body_newline(lines[edit.line - 1])
|
||||
heading_match = HEADING_RE.match(body)
|
||||
if heading_match is None:
|
||||
raise ValueError(f"line {edit.line} is not an editable ATX heading")
|
||||
|
||||
indent, _hashes, spacing, rest = heading_match.groups()
|
||||
lines[edit.line - 1] = f"{indent}{'#' * edit.to_level}{spacing}{rest}{newline}"
|
||||
changed.append(
|
||||
{
|
||||
"line": edit.line,
|
||||
"from_level": heading.level,
|
||||
"to_level": edit.to_level,
|
||||
"text": heading.text,
|
||||
"reason": edit.reason,
|
||||
}
|
||||
)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def apply_edits(
|
||||
source: pathlib.Path,
|
||||
edits: list[Edit],
|
||||
output: pathlib.Path,
|
||||
report: pathlib.Path | None = None,
|
||||
mode: str = "quick",
|
||||
plan_label: str = "in-memory edits",
|
||||
) -> int:
|
||||
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
changed = _apply_edits_to_lines(lines, edits)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
if report is not None:
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
report.write_text(
|
||||
format_report(source, output, plan_label, mode, changed),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return len(changed)
|
||||
|
||||
|
||||
def apply_edits_in_place(
|
||||
source: pathlib.Path,
|
||||
edits: list[Edit],
|
||||
mode: str = "quick",
|
||||
report: pathlib.Path | None = None,
|
||||
) -> int:
|
||||
return apply_edits(
|
||||
source=source,
|
||||
edits=edits,
|
||||
output=source,
|
||||
report=report,
|
||||
mode=mode,
|
||||
plan_label="in-place edits",
|
||||
)
|
||||
|
||||
|
||||
def apply_plan(
|
||||
source: pathlib.Path,
|
||||
plan: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
report: pathlib.Path | None,
|
||||
) -> int:
|
||||
mode, edits = parse_plan(plan)
|
||||
return apply_edits(source, edits, output, report, mode, str(plan))
|
||||
|
||||
|
||||
def format_report(
|
||||
source: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
plan: pathlib.Path | str,
|
||||
mode: str,
|
||||
changed: list[dict[str, Any]],
|
||||
) -> str:
|
||||
lines = [
|
||||
"# Semantic Heading Repair Report",
|
||||
"",
|
||||
f"- Source: `{source}`",
|
||||
f"- Output: `{output}`",
|
||||
f"- Plan: `{plan}`",
|
||||
f"- Mode: `{mode}`",
|
||||
f"- Changed headings: {len(changed)}",
|
||||
"",
|
||||
"| Line | From | To | Heading | Reason |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for item in changed:
|
||||
lines.append(
|
||||
"| {line} | {from_level} | {to_level} | {text} | {reason} |".format(
|
||||
**item
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "fix_markdown_titles.py"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class FixMarkdownTitlesTest(unittest.TestCase):
|
||||
def test_shifts_atx_headings_and_leaves_code_blocks(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = pathlib.Path(tmp) / "reply.md"
|
||||
target.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"Intro",
|
||||
"",
|
||||
"# One",
|
||||
"## Two ##",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"> # quoted content",
|
||||
" # indented code",
|
||||
"###### Six",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(str(target), "--levels", "2")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
target.read_text(encoding="utf-8"),
|
||||
"\n".join(
|
||||
[
|
||||
"Intro",
|
||||
"",
|
||||
"### One",
|
||||
"#### Two ##",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"> # quoted content",
|
||||
" # indented code",
|
||||
"###### Six",
|
||||
"",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def test_dry_run_prints_without_writing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = pathlib.Path(tmp) / "reply.md"
|
||||
target.write_text("# One\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(target), "--levels", "1", "--dry-run")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, "## One\n")
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), "# One\n")
|
||||
|
||||
def test_output_writes_to_separate_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "reply.md"
|
||||
output = pathlib.Path(tmp) / "fixed.md"
|
||||
source.write_text("# One\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(source), "--levels", "3", "--output", str(output))
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(source.read_text(encoding="utf-8"), "# One\n")
|
||||
self.assertEqual(output.read_text(encoding="utf-8"), "#### One\n")
|
||||
|
||||
def test_rejects_non_positive_levels(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = pathlib.Path(tmp) / "reply.md"
|
||||
target.write_text("# One\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(target), "--levels", "0")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("positive integer", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "scripts"
|
||||
/ "semantic_heading_repair.py"
|
||||
)
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("semantic_heading_repair", SCRIPT)
|
||||
repair = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = repair
|
||||
SPEC.loader.exec_module(repair)
|
||||
|
||||
|
||||
class SemanticHeadingRepairTest(unittest.TestCase):
|
||||
def test_inspect_reports_only_real_atx_headings(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
source.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Title",
|
||||
"",
|
||||
"## Section",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"> # quoted content",
|
||||
" # indented code",
|
||||
"### Child",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
payload = repair.inspect_payload(source)
|
||||
|
||||
self.assertEqual(
|
||||
[(item["line"], item["level"], item["text"]) for item in payload["headings"]],
|
||||
[(1, 1, "Title"), (3, 2, "Section"), (9, 3, "Child")],
|
||||
)
|
||||
|
||||
def test_write_inspection_file_uses_utf8(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
output = pathlib.Path(tmp) / "heading-map.json"
|
||||
source.write_text("# 标题\n", encoding="utf-8")
|
||||
|
||||
repair.write_inspection(source, output, output_format="json")
|
||||
|
||||
payload = json.loads(output.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["headings"][0]["text"], "标题")
|
||||
|
||||
def test_apply_plan_rewrites_only_requested_heading_levels(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
output = pathlib.Path(tmp) / "fixed.md"
|
||||
report = pathlib.Path(tmp) / "report.md"
|
||||
plan = pathlib.Path(tmp) / "plan.json"
|
||||
source.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 1",
|
||||
"",
|
||||
"## GPT",
|
||||
"",
|
||||
"# Wrong parent",
|
||||
"",
|
||||
"## Wrong child",
|
||||
"",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mode": "discussion",
|
||||
"edits": [
|
||||
{
|
||||
"line": 5,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "GPT reply content belongs under ## GPT.",
|
||||
},
|
||||
{
|
||||
"line": 7,
|
||||
"from_level": 2,
|
||||
"to_level": 4,
|
||||
"reason": "Child heading follows the repaired parent.",
|
||||
},
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
changed = repair.apply_plan(source, plan, output, report)
|
||||
|
||||
self.assertEqual(changed, 2)
|
||||
self.assertEqual(
|
||||
output.read_text(encoding="utf-8"),
|
||||
"\n".join(
|
||||
[
|
||||
"# 1",
|
||||
"",
|
||||
"## GPT",
|
||||
"",
|
||||
"### Wrong parent",
|
||||
"",
|
||||
"#### Wrong child",
|
||||
"",
|
||||
"```md",
|
||||
"# Not a heading",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
),
|
||||
)
|
||||
report_text = report.read_text(encoding="utf-8")
|
||||
self.assertIn("| 5 | 1 | 3 | Wrong parent |", report_text)
|
||||
self.assertIn("GPT reply content belongs under ## GPT.", report_text)
|
||||
|
||||
def test_apply_plan_rejects_stale_or_wrong_source_plans(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = pathlib.Path(tmp) / "source.md"
|
||||
plan = pathlib.Path(tmp) / "plan.json"
|
||||
source.write_text("# Title\n\n## Section\n", encoding="utf-8")
|
||||
plan.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"edits": [
|
||||
{
|
||||
"line": 3,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "This plan was made for another source.",
|
||||
}
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "expected level 1"):
|
||||
repair.apply_plan(source, plan, pathlib.Path(tmp) / "fixed.md", None)
|
||||
|
||||
def test_apply_edits_in_place_overwrites_source_without_sidecars(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
source = root / "source.md"
|
||||
source.write_text("# Title\n\n# Wrong peer\n", encoding="utf-8")
|
||||
|
||||
changed = repair.apply_edits_in_place(
|
||||
source,
|
||||
[
|
||||
repair.Edit(
|
||||
line=3,
|
||||
from_level=1,
|
||||
to_level=2,
|
||||
reason="Artifact sections after the title are children.",
|
||||
)
|
||||
],
|
||||
mode="artifact",
|
||||
)
|
||||
|
||||
self.assertEqual(changed, 1)
|
||||
self.assertEqual(
|
||||
source.read_text(encoding="utf-8"),
|
||||
"# Title\n\n## Wrong peer\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in root.iterdir()),
|
||||
["source.md"],
|
||||
)
|
||||
|
||||
def test_prepare_batch_scaffold_accepts_file_array(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
first = root / "one.md"
|
||||
second = root / "two.md"
|
||||
output_dir = root / "out"
|
||||
first.write_text("# One\n\n# Bad\n", encoding="utf-8")
|
||||
second.write_text("# Two\n\n## Good\n", encoding="utf-8")
|
||||
|
||||
items = repair.prepare_batch_scaffold(
|
||||
[first, second],
|
||||
mode="artifact",
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 2)
|
||||
for item in items:
|
||||
self.assertEqual(item["mode"], "artifact")
|
||||
self.assertTrue(pathlib.Path(item["heading_map"]).exists())
|
||||
self.assertTrue(pathlib.Path(item["plan"]).exists())
|
||||
self.assertTrue(str(item["fixed"]).endswith(".fixed.md"))
|
||||
plan_payload = json.loads(pathlib.Path(item["plan"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(plan_payload["mode"], "artifact")
|
||||
self.assertEqual(plan_payload["edits"], [])
|
||||
|
||||
batch_report = output_dir / "fix-title-batch-report.md"
|
||||
self.assertIn("one.md", batch_report.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -38,9 +38,8 @@ C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pip install -e .\skills\vo
|
|||
Install and authenticate MiniMax `mmx`:
|
||||
|
||||
```powershell
|
||||
npm.cmd install -g mmx-cli
|
||||
cmd /c mmx auth login
|
||||
cmd /c mmx auth status
|
||||
npm install -g mmx-cli
|
||||
mmx auth login
|
||||
```
|
||||
|
||||
## Install Skill Wrapper
|
||||
|
|
@ -61,27 +60,18 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
|
|||
|
||||
Testing is intentionally separate from migration.
|
||||
|
||||
Run source tests from the repository root. Disabling pytest's cache provider avoids writes under a read-only installed Skill directory:
|
||||
Run unit tests only after installation is ready:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
||||
conda run -n skills-vault python -B -m pytest .\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation
|
||||
python -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
|
||||
```
|
||||
|
||||
Validate the Skill structure and installed copy separately:
|
||||
Or without activating the environment:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault python -B .\scripts\quick_validate.py .\skills\voice-generation
|
||||
conda run -n skills-vault python -B .\scripts\quick_validate.py C:\Users\wangq\.agents\skills\voice-generation
|
||||
C:\Users\wangq\.conda\envs\skills-vault\python.exe -B -m pytest C:\Users\wangq\.agents\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation-installed
|
||||
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
||||
C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
|
||||
```
|
||||
|
||||
Smoke tests may call real MiniMax services and consume quota. Do not run them unless explicitly requested.
|
||||
|
||||
For a real long-form test, create an isolated project under the repository `tmp` directory, populate `voices.json` and `scripts\`, then run from that project root:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault voice-gen gen --voice <voice-name> scripts --out output --format mp3
|
||||
```
|
||||
|
||||
The command sends the extracted narration text to MiniMax. Confirm that external transmission is intended before running it.
|
||||
|
|
|
|||
|
|
@ -69,25 +69,3 @@ conda environment: shared skills-vault
|
|||
editable package installed in shared env: yes
|
||||
old dedicated conda env removed: C:\Users\wangq\miniconda3\envs\voice-gen
|
||||
```
|
||||
|
||||
## Post-Migration Windows Hardening
|
||||
|
||||
Validated on 2026-07-10:
|
||||
|
||||
- Resolve the npm `mmx.CMD` shim and execute it through `cmd /c` on Windows.
|
||||
- Pass narration through a temporary UTF-8 file with `--text-file`; do not pass long text through `--text` or use mmx-cli's Windows-incompatible `--text-file -` path.
|
||||
- Remove the temporary text file after success or failure.
|
||||
- Escape non-ASCII path characters in CLI status lines as `\uXXXX` so `conda run` remains safe on GBK consoles; generated files retain their Unicode names.
|
||||
- Force repository tests to import the source tree instead of an older `.agents` installation.
|
||||
|
||||
Verification evidence:
|
||||
|
||||
```text
|
||||
source tests: 40 passed, 1 skipped
|
||||
installed-copy tests: 40 passed, 1 skipped
|
||||
real long-form voice: BroTsong-2026-06-10
|
||||
real input: 2602 extracted characters / 7030 UTF-8 bytes
|
||||
real output: tmp\voice-generation-e2e-20260710-01\output\01.从想法到作品口播文案.mp3
|
||||
audio: 505.152 seconds, 32000 Hz, 128000 bps, 14032 MPEG frames
|
||||
temporary text files after completion: 0
|
||||
```
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
|
|||
External requirements:
|
||||
|
||||
- Conda
|
||||
- `mmx` CLI v1.0.16+ (`npm.cmd install -g mmx-cli` on Windows)
|
||||
- `cmd /c mmx auth login` once on Windows
|
||||
- `mmx` CLI v1.0.16+ (`npm install -g mmx-cli`)
|
||||
- `mmx auth login` once
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -70,9 +70,7 @@ The CLI wraps MiniMax voice operations:
|
|||
- Uploaded files are registered through the MiniMax voice clone API.
|
||||
- Synthesis uses `mmx speech synthesize`.
|
||||
|
||||
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed. Synthesis text is written to a short-lived UTF-8 file next to the requested output and passed with `--text-file`; the file is removed after each call. This avoids Windows command-line length and quoting limits. It also avoids mmx-cli's current `--text-file -` stdin implementation, which resolves `/dev/stdin` as a drive-local path on Windows.
|
||||
|
||||
CLI status lines escape non-ASCII path characters as ASCII `\uXXXX` sequences so `conda run` remains safe on GBK Windows consoles. The files themselves retain their original Unicode names.
|
||||
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed.
|
||||
|
||||
## Project Layout
|
||||
|
||||
|
|
@ -80,6 +78,7 @@ CLI status lines escape non-ASCII path characters as ASCII `\uXXXX` sequences so
|
|||
skills/voice-generation/
|
||||
SKILL.md
|
||||
INSTALL.md
|
||||
environment.yml
|
||||
pyproject.toml
|
||||
src/voice_gen/
|
||||
tests/
|
||||
|
|
@ -97,15 +96,13 @@ output/
|
|||
## Development
|
||||
|
||||
```bash
|
||||
# From the repository root
|
||||
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests -p no:cacheprovider --basetemp tmp/pytest-voice-generation
|
||||
# Inside the skills-vault Conda env
|
||||
python -m pytest
|
||||
|
||||
# Smoke tests may call real MiniMax services and consume quota.
|
||||
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests --run-smoke -p no:cacheprovider --basetemp tmp/pytest-voice-generation-smoke
|
||||
python -m pytest --run-smoke
|
||||
```
|
||||
|
||||
The test configuration forces imports from this repository's `src` tree so a separately installed `.agents/skills` copy cannot make source tests pass accidentally.
|
||||
|
||||
## As An Agentic Skill
|
||||
|
||||
The root `SKILL.md` exposes this tool to Agentic systems. The public installed Skill path is:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This Skill operates the local `voice-gen` Python CLI. The CLI wraps MiniMax `mmx
|
|||
|
||||
- Activate the configured Conda environment before running the CLI.
|
||||
- Ensure the `voice-gen` package has been installed in editable mode from the repository source.
|
||||
- Ensure the `mmx` CLI is installed and authenticated. On Windows, verify with `cmd /c mmx auth status` to avoid PowerShell shim policy issues.
|
||||
- Ensure the `mmx` CLI is installed and authenticated with `mmx auth login`.
|
||||
- Run CLI commands from the user's project root unless the user specifies another working directory.
|
||||
|
||||
## Workflow
|
||||
|
|
@ -61,8 +61,6 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
|
|||
|
||||
- Input scripts are Markdown files matching `*.md`.
|
||||
- Frontmatter, headings, code fences, list markers, bold, italic, and inline code markup are stripped before synthesis.
|
||||
- Synthesis text is passed to mmx through a temporary UTF-8 file, not a command-line `--text` value or `--text-file -`; the temporary file is removed after the call.
|
||||
- Status output escapes non-ASCII path characters as `\uXXXX` sequences for Windows console safety; generated files retain their original Unicode names.
|
||||
- Output files are named `<script-stem>.<format>`.
|
||||
- The local voice registry is `voices.json` in the current working directory.
|
||||
|
||||
|
|
@ -70,7 +68,7 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
|
|||
|
||||
- If `voice-gen voices list` is empty, ask the user to register or pull voices first.
|
||||
- If a voice is not found, list available voices and ask the user to choose one.
|
||||
- If `mmx` authentication fails on Windows, ask the user to run `cmd /c mmx auth status` and `cmd /c mmx auth login`.
|
||||
- If `mmx` authentication fails, ask the user to run `mmx auth status` and `mmx auth login`.
|
||||
- If quota fails, surface the quota error and suggest waiting for the next quota window or upgrading.
|
||||
- If the request is for music, sound effects, video, image, or non-MiniMax TTS, do not use this Skill.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@ _MMX_CONFIG_PATH = Path.home() / ".mmx" / "config.json"
|
|||
_oauth_token_cache: str | None = None
|
||||
|
||||
|
||||
def _ascii_safe(value: object) -> str:
|
||||
"""Return deterministic ASCII text for status output on Windows consoles."""
|
||||
return str(value).encode("ascii", errors="backslashreplace").decode("ascii")
|
||||
|
||||
|
||||
def _load_oauth_token() -> str:
|
||||
"""Load the OAuth access token from ~/.mmx/config.json (cached)."""
|
||||
global _oauth_token_cache
|
||||
|
|
@ -446,13 +441,10 @@ def cmd_gen(args: argparse.Namespace) -> int:
|
|||
out_path=out_path,
|
||||
format=args.format,
|
||||
)
|
||||
print(f"OK {_ascii_safe(md.name)} -> {_ascii_safe(out_path)}")
|
||||
print(f"OK {md.name} -> {out_path}")
|
||||
successes += 1
|
||||
except Exception as e: # noqa: BLE001 - log and continue
|
||||
print(
|
||||
f"ERROR {_ascii_safe(md.name)}: {_ascii_safe(e)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"ERROR {md.name}: {e}", file=sys.stderr)
|
||||
failures += 1
|
||||
print(f"done: {successes} ok, {failures} failed")
|
||||
return 0 if failures == 0 else 1
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
|
@ -12,7 +11,7 @@ from .errors import MmxError
|
|||
|
||||
|
||||
def _build_cmd(
|
||||
text_file: Path,
|
||||
text: str,
|
||||
voice_id: int | str,
|
||||
out_path: Path,
|
||||
format: str = "mp3",
|
||||
|
|
@ -26,7 +25,7 @@ def _build_cmd(
|
|||
"synthesize",
|
||||
"--non-interactive",
|
||||
"--quiet",
|
||||
"--text-file", str(text_file),
|
||||
"--text", text,
|
||||
"--voice", str(voice_id),
|
||||
"--format", format,
|
||||
"--out", str(out_path),
|
||||
|
|
@ -80,43 +79,28 @@ def synthesize(
|
|||
"""
|
||||
if run is None:
|
||||
run = _default_run
|
||||
cmd = _build_cmd(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
out_path=out_path,
|
||||
format=format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
suffix=".txt",
|
||||
prefix=".voice-gen-",
|
||||
dir=out_path.parent,
|
||||
delete=False,
|
||||
) as text_handle:
|
||||
text_handle.write(text)
|
||||
text_path = Path(text_handle.name)
|
||||
|
||||
try:
|
||||
cmd = _build_cmd(
|
||||
text_file=text_path,
|
||||
voice_id=voice_id,
|
||||
out_path=out_path,
|
||||
format=format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
result = run(cmd)
|
||||
if result.returncode != 0:
|
||||
raise MmxError(
|
||||
exit_code=result.returncode,
|
||||
stderr=result.stderr or "",
|
||||
command=" ".join(cmd),
|
||||
)
|
||||
if not out_path.exists():
|
||||
# mmx sometimes writes hex-decoded content; treat as failure if file absent
|
||||
raise MmxError(
|
||||
exit_code=1,
|
||||
stderr=f"mmx exited 0 but {out_path} was not created",
|
||||
command=" ".join(cmd),
|
||||
)
|
||||
result = run(cmd)
|
||||
if result.returncode != 0:
|
||||
raise MmxError(
|
||||
exit_code=result.returncode,
|
||||
stderr=result.stderr or "",
|
||||
command=" ".join(cmd),
|
||||
)
|
||||
if not out_path.exists():
|
||||
# mmx sometimes writes hex-decoded content; treat as failure if file absent
|
||||
raise MmxError(
|
||||
exit_code=1,
|
||||
stderr=f"mmx exited 0 but {out_path} was not created",
|
||||
command=" ".join(cmd),
|
||||
)
|
||||
finally:
|
||||
text_path.unlink(missing_ok=True)
|
||||
return out_path
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption("--run-smoke", action="store_true", default=False,
|
||||
help="Run real-mmx smoke tests (consumes quota)")
|
||||
|
|
|
|||
|
|
@ -164,41 +164,6 @@ def test_gen_batch_end_to_end(tmp_path, monkeypatch):
|
|||
assert cmd[i + 1] == "anchor"
|
||||
|
||||
|
||||
def test_gen_status_output_is_ascii_safe(tmp_path, monkeypatch):
|
||||
import io
|
||||
import sys
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert main(["init"]) == 0
|
||||
(tmp_path / "scripts" / "中文口播.md").write_text("测试。", encoding="utf-8")
|
||||
(tmp_path / "voices.json").write_text(json.dumps({
|
||||
"voices": {
|
||||
"anchor": {
|
||||
"file_id": 42,
|
||||
"filename": "a.mp3",
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
}
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
from voice_gen import synthesizer
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
out = Path(cmd[cmd.index("--out") + 1])
|
||||
out.write_bytes(b"audio")
|
||||
return type("R", (), {"returncode": 0, "stderr": ""})()
|
||||
|
||||
monkeypatch.setattr(synthesizer, "_default_run", fake_run)
|
||||
buffer = io.BytesIO()
|
||||
ascii_stdout = io.TextIOWrapper(buffer, encoding="ascii", errors="strict")
|
||||
monkeypatch.setattr(sys, "stdout", ascii_stdout)
|
||||
|
||||
assert main(["gen", "--voice", "anchor", "scripts"]) == 0
|
||||
ascii_stdout.flush()
|
||||
output = buffer.getvalue().decode("ascii")
|
||||
assert "\\u4e2d\\u6587\\u53e3\\u64ad.md" in output
|
||||
|
||||
|
||||
def test_voices_add_rejects_duplicate_local_name(tmp_path, monkeypatch):
|
||||
"""Re-adding a file whose stem matches an existing local entry errors out."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import voice_gen
|
||||
|
||||
|
||||
def test_tests_import_repository_source():
|
||||
expected = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src"
|
||||
/ "voice_gen"
|
||||
/ "__init__.py"
|
||||
)
|
||||
assert Path(voice_gen.__file__).resolve() == expected
|
||||
|
|
@ -8,14 +8,13 @@ from voice_gen.errors import MmxError
|
|||
|
||||
def test_build_cmd_minimum():
|
||||
cmd = _build_cmd(
|
||||
text_file=Path("input.txt"),
|
||||
text="hi",
|
||||
voice_id=123,
|
||||
out_path=Path("out.mp3"),
|
||||
format="mp3",
|
||||
)
|
||||
assert cmd[:5] == ["mmx", "speech", "synthesize", "--non-interactive", "--quiet"]
|
||||
assert "--text-file" in cmd and "input.txt" in cmd
|
||||
assert "--text" not in cmd
|
||||
assert "--text" in cmd and "hi" in cmd
|
||||
assert "--voice" in cmd and "123" in cmd
|
||||
assert "--out" in cmd and "out.mp3" in cmd
|
||||
assert "--format" in cmd and "mp3" in cmd
|
||||
|
|
@ -46,32 +45,6 @@ def test_synthesize_invokes_mmx_and_returns_path(tmp_path):
|
|||
assert captured["cmd"][captured["cmd"].index("--voice") + 1] == "999"
|
||||
|
||||
|
||||
def test_synthesize_passes_long_utf8_text_via_temporary_file(tmp_path):
|
||||
text = ("这是一段用于验证 Windows 长命令行安全性的中文口播稿。\n" * 500)[:9000]
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
assert "--text" not in cmd
|
||||
text_path = Path(cmd[cmd.index("--text-file") + 1])
|
||||
captured["text_path"] = text_path
|
||||
captured["text"] = text_path.read_text(encoding="utf-8")
|
||||
out = Path(cmd[cmd.index("--out") + 1])
|
||||
out.write_bytes(b"fake-audio")
|
||||
return type("R", (), {"returncode": 0, "stderr": ""})()
|
||||
|
||||
out = synthesize(
|
||||
text=text,
|
||||
voice_id="test-voice",
|
||||
out_path=tmp_path / "long.mp3",
|
||||
run=fake_run,
|
||||
)
|
||||
|
||||
assert out.exists()
|
||||
assert captured["text"] == text
|
||||
assert not captured["text_path"].exists()
|
||||
|
||||
|
||||
def test_synthesize_raises_mmxerror_on_nonzero(tmp_path):
|
||||
def fake_run(cmd, **kwargs):
|
||||
return type("R", (), {"returncode": 3, "stderr": "auth failed"})()
|
||||
|
|
|
|||
Loading…
Reference in New Issue