Compare commits

...

3 Commits

21 changed files with 1074 additions and 307 deletions

View File

@ -28,6 +28,7 @@ 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:
@ -40,6 +41,7 @@ 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.

View File

@ -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` | 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 |
| `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 |
| `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. | `D:\AI\ClaudeCode\voice-ge` | `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. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\voice-generation` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
## Status Values

76
scripts/quick_validate.py Normal file
View File

@ -0,0 +1,76 @@
#!/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:]))

View File

@ -20,7 +20,7 @@ conda activate skills-vault
## Test
```powershell
python -m unittest discover -s skills\fix-title\tests -v
conda run -n skills-vault python -B -m unittest discover -s skills\fix-title\tests -v
```
## Install To Public Skill Surface

View File

@ -1,52 +1,199 @@
---
name: fix-title
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.
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."
---
# Fix Title
## Overview
## Agentic Input
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.
Use this skill from an agent prompt, not as a Python CLI.
The default operation adds two heading levels, turning `# Title` into `### Title`, which fits content placed under a parent heading like `## GPT`.
Required inputs:
## Workflow
- `files`: one or more Markdown file paths.
- `mode`: `discussion` or `artifact`.
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.
Optional inputs:
## Commands
- `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.
```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
Example user-facing request:
```text
Use $fix-title with mode=artifact on C:\path\one.md
```
On Windows PowerShell, quote paths that contain spaces:
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:
```powershell
python .\scripts\fix_markdown_titles.py "C:\path with spaces\reply.md" --levels 2
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
```
## 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.

View File

@ -1,3 +1,3 @@
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.
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."

View File

@ -1,13 +1,17 @@
# Manual Test Input
# Manual Test
Place one raw Markdown file here for manual validation.
Use this directory for small manual validation files when needed.
Recommended filename:
Recommended files:
```text
raw-reply.md
raw-discussion.md
raw-artifact.md
audit-heading-plan.json
```
The file should be an original pasted LLM reply before heading repair.
The raw files should contain disposable copies of original LLM/GPT Markdown before heading repair.
After the file is added, run the Skill against a copy or with `--output` first, then compare the result before updating the installed version.
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.

View File

@ -1,116 +0,0 @@
#!/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:]))

View File

@ -0,0 +1,390 @@
#!/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)

View File

@ -1,100 +0,0 @@
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()

View File

@ -0,0 +1,221 @@
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()

View File

@ -38,8 +38,9 @@ C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pip install -e .\skills\vo
Install and authenticate MiniMax `mmx`:
```powershell
npm install -g mmx-cli
mmx auth login
npm.cmd install -g mmx-cli
cmd /c mmx auth login
cmd /c mmx auth status
```
## Install Skill Wrapper
@ -60,18 +61,27 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
Testing is intentionally separate from migration.
Run unit tests only after installation is ready:
Run source tests from the repository root. Disabling pytest's cache provider avoids writes under a read-only installed Skill directory:
```powershell
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
python -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
conda run -n skills-vault python -B -m pytest .\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation
```
Or without activating the environment:
Validate the Skill structure and installed copy separately:
```powershell
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
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
```
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.

View File

@ -69,3 +69,25 @@ 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
```

View File

@ -28,8 +28,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
External requirements:
- Conda
- `mmx` CLI v1.0.16+ (`npm install -g mmx-cli`)
- `mmx auth login` once
- `mmx` CLI v1.0.16+ (`npm.cmd install -g mmx-cli` on Windows)
- `cmd /c mmx auth login` once on Windows
## Quick Start
@ -70,7 +70,9 @@ 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.
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.
## Project Layout
@ -78,7 +80,6 @@ On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed thro
skills/voice-generation/
SKILL.md
INSTALL.md
environment.yml
pyproject.toml
src/voice_gen/
tests/
@ -96,13 +97,15 @@ output/
## Development
```bash
# Inside the skills-vault Conda env
python -m pytest
# 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
# Smoke tests may call real MiniMax services and consume quota.
python -m pytest --run-smoke
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests --run-smoke -p no:cacheprovider --basetemp tmp/pytest-voice-generation-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:

View File

@ -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 with `mmx auth login`.
- Ensure the `mmx` CLI is installed and authenticated. On Windows, verify with `cmd /c mmx auth status` to avoid PowerShell shim policy issues.
- Run CLI commands from the user's project root unless the user specifies another working directory.
## Workflow
@ -61,6 +61,8 @@ 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.
@ -68,7 +70,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, ask the user to run `mmx auth status` and `mmx auth login`.
- If `mmx` authentication fails on Windows, ask the user to run `cmd /c mmx auth status` and `cmd /c 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.

View File

@ -16,6 +16,11 @@ _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
@ -441,10 +446,13 @@ def cmd_gen(args: argparse.Namespace) -> int:
out_path=out_path,
format=args.format,
)
print(f"OK {md.name} -> {out_path}")
print(f"OK {_ascii_safe(md.name)} -> {_ascii_safe(out_path)}")
successes += 1
except Exception as e: # noqa: BLE001 - log and continue
print(f"ERROR {md.name}: {e}", file=sys.stderr)
print(
f"ERROR {_ascii_safe(md.name)}: {_ascii_safe(e)}",
file=sys.stderr,
)
failures += 1
print(f"done: {successes} ok, {failures} failed")
return 0 if failures == 0 else 1

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Callable, Optional
@ -11,7 +12,7 @@ from .errors import MmxError
def _build_cmd(
text: str,
text_file: Path,
voice_id: int | str,
out_path: Path,
format: str = "mp3",
@ -25,7 +26,7 @@ def _build_cmd(
"synthesize",
"--non-interactive",
"--quiet",
"--text", text,
"--text-file", str(text_file),
"--voice", str(voice_id),
"--format", format,
"--out", str(out_path),
@ -79,8 +80,22 @@ def synthesize(
"""
if run is None:
run = _default_run
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=text,
text_file=text_path,
voice_id=voice_id,
out_path=out_path,
format=format,
@ -88,7 +103,6 @@ def synthesize(
volume=volume,
pitch=pitch,
)
out_path.parent.mkdir(parents=True, exist_ok=True)
result = run(cmd)
if result.returncode != 0:
raise MmxError(
@ -103,4 +117,6 @@ def synthesize(
stderr=f"mmx exited 0 but {out_path} was not created",
command=" ".join(cmd),
)
finally:
text_path.unlink(missing_ok=True)
return out_path

View File

@ -1,7 +1,14 @@
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)")

View File

@ -164,6 +164,41 @@ 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)

View File

@ -0,0 +1,13 @@
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

View File

@ -8,13 +8,14 @@ from voice_gen.errors import MmxError
def test_build_cmd_minimum():
cmd = _build_cmd(
text="hi",
text_file=Path("input.txt"),
voice_id=123,
out_path=Path("out.mp3"),
format="mp3",
)
assert cmd[:5] == ["mmx", "speech", "synthesize", "--non-interactive", "--quiet"]
assert "--text" in cmd and "hi" in cmd
assert "--text-file" in cmd and "input.txt" in cmd
assert "--text" not 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
@ -45,6 +46,32 @@ 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"})()