neo-skill 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.shared/skill-creator/data/domains.json +56 -0
  2. package/.shared/skill-creator/data/output-patterns.csv +8 -0
  3. package/.shared/skill-creator/data/resource-patterns.csv +8 -0
  4. package/.shared/skill-creator/data/skill-reasoning.csv +11 -0
  5. package/.shared/skill-creator/data/trigger-patterns.csv +8 -0
  6. package/.shared/skill-creator/data/validation-rules.csv +11 -0
  7. package/.shared/skill-creator/data/workflow-patterns.csv +6 -0
  8. package/.shared/skill-creator/scripts/generate.py +300 -0
  9. package/.shared/skill-creator/scripts/package.py +140 -0
  10. package/.shared/skill-creator/scripts/search.py +231 -0
  11. package/.shared/skill-creator/scripts/validate.py +213 -0
  12. package/LICENSE +21 -0
  13. package/README.md +117 -0
  14. package/bin/omni-skill.js +55 -0
  15. package/bin/skill-creator.js +55 -0
  16. package/package.json +25 -0
  17. package/skills/review-gate/references/review-gate.md +228 -0
  18. package/skills/review-gate/skillspec.json +131 -0
  19. package/skills/skill-creator/references/output-patterns.md +82 -0
  20. package/skills/skill-creator/references/pre-delivery-checklist.md +70 -0
  21. package/skills/skill-creator/references/requirement-collection.md +80 -0
  22. package/skills/skill-creator/references/skill-system-design.md +112 -0
  23. package/skills/skill-creator/references/sources.md +5 -0
  24. package/skills/skill-creator/references/workflow-step-editing.md +103 -0
  25. package/skills/skill-creator/references/workflows.md +28 -0
  26. package/skills/skill-creator/scripts/init_skill.py +34 -0
  27. package/skills/skill-creator/scripts/package_skill.py +34 -0
  28. package/skills/skill-creator/scripts/validate_skill.py +35 -0
  29. package/skills/skill-creator/skillspec.json +117 -0
  30. package/src/omni_skill/__init__.py +1 -0
  31. package/src/omni_skill/cli.py +270 -0
  32. package/src/skill_creator/__init__.py +1 -0
  33. package/src/skill_creator/cli.py +278 -0
  34. package/src/skill_creator/packaging/package.py +30 -0
  35. package/src/skill_creator/packaging/ziputil.py +26 -0
  36. package/src/skill_creator/spec/model.py +111 -0
  37. package/src/skill_creator/spec/render.py +108 -0
  38. package/src/skill_creator/spec/validate.py +18 -0
  39. package/src/skill_creator/targets/claude.py +53 -0
  40. package/src/skill_creator/targets/common.py +46 -0
  41. package/src/skill_creator/targets/cursor.py +34 -0
  42. package/src/skill_creator/targets/github_skills.py +40 -0
  43. package/src/skill_creator/targets/windsurf.py +123 -0
  44. package/src/skill_creator/util/frontmatter.py +24 -0
  45. package/src/skill_creator/util/fs.py +32 -0
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+
7
+ from ..util.fs import read_json
8
+
9
+
10
+ def _as_list(x: Any) -> List[Any]:
11
+ if x is None:
12
+ return []
13
+ if isinstance(x, list):
14
+ return x
15
+ return [x]
16
+
17
+
18
+ def _kebab_case_ok(name: str) -> bool:
19
+ return (
20
+ bool(name)
21
+ and all(c.islower() or c.isdigit() or c == "-" for c in name)
22
+ and "--" not in name
23
+ and not name.startswith("-")
24
+ and not name.endswith("-")
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class WorkflowStep:
30
+ id: str
31
+ title: str
32
+ kind: str = "action" # action|gate|branch
33
+ commands: List[str] = field(default_factory=list)
34
+ notes: str = ""
35
+
36
+ @staticmethod
37
+ def from_dict(d: Dict[str, Any]) -> "WorkflowStep":
38
+ return WorkflowStep(
39
+ id=str(d.get("id", "")).strip(),
40
+ title=str(d.get("title", "")).strip(),
41
+ kind=str(d.get("kind", "action")).strip(),
42
+ commands=[str(c) for c in _as_list(d.get("commands"))],
43
+ notes=str(d.get("notes", "")).strip(),
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class SkillSpec:
49
+ version: int
50
+ name: str
51
+ description: str
52
+
53
+
54
+ # Prompting
55
+ questions: List[str] = field(default_factory=list) # must be <= 10
56
+ triggers: List[str] = field(default_factory=list)
57
+ freedom_level: str = "low" # low|medium|high
58
+
59
+ # Workflow + assets
60
+ workflow_type: str = "sequential" # sequential|conditional
61
+ steps: List[WorkflowStep] = field(default_factory=list)
62
+
63
+ references: List[str] = field(default_factory=list)
64
+ scripts: List[str] = field(default_factory=list)
65
+ assets: List[str] = field(default_factory=list)
66
+
67
+ @staticmethod
68
+ def from_path(path: Path) -> "SkillSpec":
69
+ data = read_json(path)
70
+ return SkillSpec.from_dict(data)
71
+
72
+ @staticmethod
73
+ def from_dict(d: Dict[str, Any]) -> "SkillSpec":
74
+ steps = [WorkflowStep.from_dict(x) for x in _as_list(d.get("workflow", {}).get("steps"))]
75
+ return SkillSpec(
76
+ version=int(d.get("version", 1)),
77
+ name=str(d.get("name", "")).strip(),
78
+ description=str(d.get("description", "")).strip(),
79
+ questions=[str(x) for x in _as_list(d.get("questions"))],
80
+ triggers=[str(x) for x in _as_list(d.get("triggers"))],
81
+ freedom_level=str(d.get("freedom_level", "low")).strip(),
82
+ workflow_type=str(d.get("workflow", {}).get("type", "sequential")).strip(),
83
+ steps=steps,
84
+ references=[str(x) for x in _as_list(d.get("references"))],
85
+ scripts=[str(x) for x in _as_list(d.get("scripts"))],
86
+ assets=[str(x) for x in _as_list(d.get("assets"))],
87
+ )
88
+
89
+ def validate_basic(self) -> List[str]:
90
+ errors: List[str] = []
91
+ if self.version != 1:
92
+ errors.append(f"Unsupported spec version: {self.version}")
93
+ if not _kebab_case_ok(self.name):
94
+ errors.append("name must be kebab-case (lowercase letters, digits, hyphen)")
95
+ if not self.description:
96
+ errors.append("description is required")
97
+ if len(self.questions) > 10:
98
+ errors.append("questions must be <= 10")
99
+ if self.freedom_level not in {"low", "medium", "high"}:
100
+ errors.append("freedom_level must be one of: low, medium, high")
101
+ if self.workflow_type not in {"sequential", "conditional"}:
102
+ errors.append("workflow.type must be sequential or conditional")
103
+ if not self.steps:
104
+ errors.append("workflow.steps must not be empty")
105
+ # minimal step validation
106
+ for i, s in enumerate(self.steps):
107
+ if not s.id:
108
+ errors.append(f"workflow.steps[{i}].id is required")
109
+ if not s.title:
110
+ errors.append(f"workflow.steps[{i}].title is required")
111
+ return errors
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ from ..util.fs import ensure_dir, copytree, copyfile
7
+ from .model import SkillSpec
8
+ from ..targets.claude import render_claude_skill_md
9
+ from ..targets.windsurf import render_windsurf_workflow_md
10
+ from ..targets.cursor import render_cursor_command_md
11
+ from ..targets.github_skills import render_github_skill_md
12
+
13
+ # Primary target is Windsurf; others are backward compat
14
+ PRIMARY_TARGET = "windsurf"
15
+ BACKWARD_COMPAT_TARGETS = ["claude", "cursor", "github"]
16
+ ALL_TARGETS = [PRIMARY_TARGET] + BACKWARD_COMPAT_TARGETS
17
+
18
+
19
+ def generate_target(repo_root: Path, spec: SkillSpec, spec_dir: Path, target: str) -> Path:
20
+ """Generate output for a specific target. Returns output path."""
21
+ refs_dir = spec_dir / "references"
22
+ scripts_dir = spec_dir / "scripts"
23
+ assets_dir = spec_dir / "assets"
24
+ skill_readme = spec_dir / "readme.txt"
25
+
26
+ if target == "windsurf":
27
+ # PRIMARY: Windsurf workflow
28
+ out_path = repo_root / ".windsurf" / "workflows" / f"{spec.name}.md"
29
+ ensure_dir(out_path.parent)
30
+ out_path.write_text(render_windsurf_workflow_md(spec), encoding="utf-8")
31
+
32
+ # Windsurf workflow data (optional)
33
+ windsurf_data_src = assets_dir / "windsurf-workflow-data"
34
+ if windsurf_data_src.exists():
35
+ windsurf_data_dst = repo_root / ".windsurf" / "workflows" / "data" / spec.name
36
+ copytree(windsurf_data_src, windsurf_data_dst)
37
+ return out_path
38
+
39
+ elif target == "claude":
40
+ # BACKWARD COMPAT: Claude SKILL.md
41
+ claude_dir = repo_root / ".claude" / "skills" / spec.name
42
+ ensure_dir(claude_dir)
43
+ out_path = claude_dir / "SKILL.md"
44
+ out_path.write_text(render_claude_skill_md(spec), encoding="utf-8")
45
+ if skill_readme.exists():
46
+ copyfile(skill_readme, claude_dir / "readme.txt")
47
+ claude_res = claude_dir / "resources"
48
+ copytree(refs_dir, claude_res / "references")
49
+ copytree(scripts_dir, claude_res / "scripts")
50
+ copytree(assets_dir, claude_res / "assets")
51
+ return out_path
52
+
53
+ elif target == "cursor":
54
+ # BACKWARD COMPAT: Cursor command
55
+ out_path = repo_root / ".cursor" / "commands" / f"{spec.name}.md"
56
+ ensure_dir(out_path.parent)
57
+ out_path.write_text(render_cursor_command_md(spec), encoding="utf-8")
58
+ return out_path
59
+
60
+ elif target == "github":
61
+ # BACKWARD COMPAT: GitHub Skills
62
+ gh_dir = repo_root / ".github" / "skills" / spec.name
63
+ ensure_dir(gh_dir)
64
+ out_path = gh_dir / "SKILL.md"
65
+ out_path.write_text(render_github_skill_md(spec), encoding="utf-8")
66
+ if skill_readme.exists():
67
+ copyfile(skill_readme, gh_dir / "readme.txt")
68
+ copytree(refs_dir, gh_dir / "references")
69
+ copytree(scripts_dir, gh_dir / "scripts")
70
+ copytree(assets_dir, gh_dir / "assets")
71
+ return out_path
72
+
73
+ else:
74
+ raise ValueError(f"Unknown target: {target}")
75
+
76
+
77
+ def generate_windsurf(repo_root: Path, spec_path: Path) -> SkillSpec:
78
+ """Generate Windsurf output only (primary target)."""
79
+ spec = SkillSpec.from_path(spec_path)
80
+ spec_dir = spec_path.parent
81
+ generate_target(repo_root, spec, spec_dir, "windsurf")
82
+ return spec
83
+
84
+
85
+ def generate_all(repo_root: Path, spec_path: Path, targets: Optional[List[str]] = None) -> SkillSpec:
86
+ """
87
+ Generate outputs for specified targets.
88
+
89
+ Args:
90
+ repo_root: Repository root path
91
+ spec_path: Path to skillspec.json
92
+ targets: List of targets to generate. Default: all targets.
93
+
94
+ Returns:
95
+ Loaded SkillSpec
96
+ """
97
+ repo_root = repo_root.resolve()
98
+ spec_path = spec_path.resolve()
99
+ spec = SkillSpec.from_path(spec_path)
100
+ spec_dir = spec_path.parent
101
+
102
+ if targets is None:
103
+ targets = ALL_TARGETS
104
+
105
+ for target in targets:
106
+ generate_target(repo_root, spec, spec_dir, target)
107
+
108
+ return spec
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from .model import SkillSpec
7
+
8
+
9
+ def validate_spec(spec_path: Path) -> List[str]:
10
+ spec = SkillSpec.from_path(spec_path)
11
+ return spec.validate_basic()
12
+
13
+
14
+ def assert_spec_valid(spec_path: Path) -> None:
15
+ errors = validate_spec(spec_path)
16
+ if errors:
17
+ joined = "\n- " + "\n- ".join(errors)
18
+ raise SystemExit(f"Spec validation failed:{joined}\n")
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from ..spec.model import SkillSpec
4
+ from ..util.frontmatter import dump_frontmatter
5
+ from .common import _render_steps, _render_resources, _render_footer
6
+
7
+
8
+ def render_claude_skill_md(spec: SkillSpec) -> str:
9
+ # Strict metadata: only name + description.
10
+ fm = dump_frontmatter({"name": spec.name, "description": spec.description})
11
+
12
+ body = []
13
+ body.append("# " + spec.name)
14
+ body.append("")
15
+ body.append("## What this skill does")
16
+ body.append(spec.description)
17
+ body.append("")
18
+
19
+ if spec.triggers:
20
+ body.append("## When to use (examples of user requests)")
21
+ for t in spec.triggers:
22
+ body.append(f"- {t}")
23
+ body.append("")
24
+
25
+ body.append("## Degrees of freedom")
26
+ body.append(
27
+ f"This skill is designed for **{spec.freedom_level}** freedom (high=heuristic, low=deterministic scripts)."
28
+ )
29
+ body.append("")
30
+
31
+ if spec.questions:
32
+ body.append("## Requirement collection (at most 10 questions)")
33
+ body.append("Ask only what is missing. Never exceed 10 questions total.")
34
+ for i, q in enumerate(spec.questions, start=1):
35
+ body.append(f"{i}. {q}")
36
+ body.append("")
37
+
38
+ body.append("## Workflow")
39
+ body.append(f"*Mode:* `{spec.workflow_type}`")
40
+ body.append("")
41
+ body.append(_render_steps(spec.steps))
42
+ body.append("")
43
+
44
+ body.append(_render_resources(spec))
45
+ body.append("## Output contract")
46
+ body.append("When invoked, produce:")
47
+ body.append("A) A proposed directory tree")
48
+ body.append("B) SKILL.md / workflow entry file(s)")
49
+ body.append("C) Any scripts/references/assets")
50
+ body.append("D) A validation checklist with pass/fail")
51
+
52
+ body.append(_render_footer())
53
+ return fm + "\n".join(body)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import List
5
+
6
+ from ..spec.model import SkillSpec, WorkflowStep
7
+
8
+
9
+ def _render_steps(steps: List[WorkflowStep]) -> str:
10
+ out = []
11
+ for i, s in enumerate(steps, start=1):
12
+ out.append(f"### Step {i} — {s.title}")
13
+ if s.kind:
14
+ out.append(f"*Type:* `{s.kind}`")
15
+ if s.notes:
16
+ out.append(s.notes)
17
+ if s.commands:
18
+ out.append("\n```bash")
19
+ out.extend(s.commands)
20
+ out.append("```\n")
21
+ return "\n".join(out)
22
+
23
+
24
+ def _render_resources(spec: SkillSpec) -> str:
25
+ lines = []
26
+ if spec.references:
27
+ lines.append("## References (loaded on-demand)")
28
+ for p in spec.references:
29
+ lines.append(f"- `{p}`")
30
+ lines.append("")
31
+ if spec.scripts:
32
+ lines.append("## Scripts (deterministic tools)")
33
+ for p in spec.scripts:
34
+ lines.append(f"- `{p}`")
35
+ lines.append("")
36
+ if spec.assets:
37
+ lines.append("## Assets")
38
+ for p in spec.assets:
39
+ lines.append(f"- `{p}`")
40
+ lines.append("")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def _render_footer() -> str:
45
+ ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
46
+ return f"\n---\nGenerated by neo-skill at {ts}.\n"
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from ..spec.model import SkillSpec
4
+
5
+
6
+ def render_cursor_command_md(spec: SkillSpec) -> str:
7
+ # Cursor's command palette prompts are typically Markdown instructions.
8
+ lines = [f"# {spec.name}", "", spec.description, ""]
9
+ if spec.triggers:
10
+ lines.append("## When to use")
11
+ lines.extend([f"- {t}" for t in spec.triggers])
12
+ lines.append("")
13
+ lines.append("## Command")
14
+ lines.append("Run these deterministic steps in the repository:")
15
+ lines.append("```bash")
16
+ if spec.name == "skill-creator":
17
+ lines.append("python3 .shared/skill-creator/scripts/generate.py skills/<skill>/skillspec.json")
18
+ lines.append("python3 .shared/skill-creator/scripts/validate.py skills/<skill>/skillspec.json")
19
+ lines.append("python3 .shared/skill-creator/scripts/package.py --target claude --skill <skill>")
20
+ else:
21
+ lines.append("omni-skill init --cursor")
22
+ lines.append("omni-skill do --agent")
23
+ lines.append("```")
24
+ lines.append("")
25
+ lines.append("## Workflow")
26
+ lines.append(f"Mode: `{spec.workflow_type}`")
27
+ lines.append("")
28
+ for i, step in enumerate(spec.steps, start=1):
29
+ lines.append(f"### {i}. {step.title}")
30
+ if step.commands:
31
+ lines.append("```bash")
32
+ lines.extend(step.commands)
33
+ lines.append("```")
34
+ return "\n".join(lines)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from ..spec.model import SkillSpec
4
+ from ..util.frontmatter import dump_frontmatter
5
+ from .common import _render_steps, _render_resources, _render_footer
6
+
7
+
8
+ def render_github_skill_md(spec: SkillSpec) -> str:
9
+ # GitHub/Agent Skills can support additional metadata; keep it conservative.
10
+ fm = dump_frontmatter(
11
+ {
12
+ "name": spec.name,
13
+ "description": spec.description,
14
+ "compatibility": "Multi-assistant (Claude/Windsurf/Cursor/GitHub Skills)",
15
+ "metadata": "Generated from canonical skillspec.json",
16
+ }
17
+ )
18
+
19
+ body = []
20
+ body.append("# " + spec.name)
21
+ body.append("")
22
+ body.append("## Overview")
23
+ body.append(spec.description)
24
+ body.append("")
25
+ if spec.triggers:
26
+ body.append("## Triggers")
27
+ for t in spec.triggers:
28
+ body.append(f"- {t}")
29
+ body.append("")
30
+ body.append("## Workflow")
31
+ body.append(f"*Mode:* `{spec.workflow_type}`")
32
+ body.append("")
33
+ body.append(_render_steps(spec.steps))
34
+ body.append("")
35
+ body.append(_render_resources(spec))
36
+ body.append("## Output contract")
37
+ body.append("- Deterministic artifacts (files, zips, reports) whenever possible")
38
+ body.append("- Machine-readable output available when scripts support `--json`")
39
+ body.append(_render_footer())
40
+ return fm + "\n".join(body)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from ..spec.model import SkillSpec
4
+
5
+
6
+ def render_windsurf_workflow_md(spec: SkillSpec) -> str:
7
+ """
8
+ Render Windsurf workflow markdown (PRIMARY target).
9
+
10
+ Follows ui-ux-pro-max patterns:
11
+ - Clear workflow steps with numbered sections
12
+ - Gate steps marked with 🚦
13
+ - References expanded to full paths
14
+ - Deterministic tools section for scripts
15
+ """
16
+ # YAML frontmatter
17
+ lines = [
18
+ "---",
19
+ f"description: {spec.description}",
20
+ "auto_execution_mode: 3",
21
+ "---",
22
+ "",
23
+ f"# {spec.name}",
24
+ "",
25
+ spec.description,
26
+ "",
27
+ ]
28
+
29
+ # Prerequisites (if scripts exist)
30
+ if spec.scripts:
31
+ lines.extend([
32
+ "## Prerequisites",
33
+ "",
34
+ "```bash",
35
+ "python3 --version || python --version",
36
+ "```",
37
+ "",
38
+ ])
39
+
40
+ # Triggers
41
+ if spec.triggers:
42
+ lines.append("## Triggers")
43
+ lines.append("")
44
+ for t in spec.triggers:
45
+ lines.append(f"- {t}")
46
+ lines.append("")
47
+
48
+ # How to Use (for skill-creator specifically)
49
+ is_skill_creator = spec.name == "skill-creator"
50
+ if is_skill_creator:
51
+ lines.extend([
52
+ "## How to Use This Workflow",
53
+ "",
54
+ "When user requests skill creation (create, build, generate, update), follow this workflow:",
55
+ "",
56
+ ])
57
+
58
+ # Workflow
59
+ lines.append("## Workflow")
60
+ lines.append("")
61
+ lines.append(f"Mode: `{spec.workflow_type}`")
62
+ lines.append("")
63
+
64
+ for i, step in enumerate(spec.steps, start=1):
65
+ kind_badge = " 🚦 GATE" if step.kind == "gate" else ""
66
+ lines.append(f"### Step {i}: {step.title}{kind_badge}")
67
+ lines.append("")
68
+
69
+ if step.notes:
70
+ notes = step.notes
71
+ # Expand references paths
72
+ notes = notes.replace("`references/", f"`skills/{spec.name}/references/")
73
+ lines.append(notes)
74
+ lines.append("")
75
+
76
+ if step.commands:
77
+ lines.append("```bash")
78
+ for cmd in step.commands:
79
+ lines.append(cmd)
80
+ lines.append("```")
81
+ lines.append("")
82
+
83
+ # References section
84
+ if spec.references:
85
+ lines.append("## References")
86
+ lines.append("")
87
+ for ref in spec.references:
88
+ ref_path = f"skills/{spec.name}/{ref}"
89
+ lines.append(f"- `{ref_path}`")
90
+ lines.append("")
91
+
92
+ # Deterministic tools (for skill-creator)
93
+ if is_skill_creator:
94
+ lines.extend([
95
+ "## Search & Generate Tools",
96
+ "",
97
+ "```bash",
98
+ "# Search skill patterns and get recommendations",
99
+ 'python3 .shared/skill-creator/scripts/search.py "<keywords>" --skill-system -p "<name>"',
100
+ "",
101
+ "# Generate Windsurf workflow (primary target)",
102
+ "python3 .shared/skill-creator/scripts/generate.py skills/<skill>/skillspec.json",
103
+ "",
104
+ "# Generate all targets (backward compat)",
105
+ "python3 .shared/skill-creator/scripts/generate.py skills/<skill>/skillspec.json --all",
106
+ "",
107
+ "# Validate spec and outputs",
108
+ "python3 .shared/skill-creator/scripts/validate.py skills/<skill>/skillspec.json",
109
+ "",
110
+ "# Package Claude .skill",
111
+ "python3 .shared/skill-creator/scripts/package.py --target claude --skill <skill>",
112
+ "```",
113
+ "",
114
+ ])
115
+
116
+ # Footer
117
+ lines.extend([
118
+ "---",
119
+ "",
120
+ f"*Generated by skill-creator. Source: `skills/{spec.name}/skillspec.json`*",
121
+ ])
122
+
123
+ return "\n".join(lines)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Any
4
+
5
+
6
+ def dump_frontmatter(fields: Dict[str, Any]) -> str:
7
+ """Dump a minimal YAML frontmatter block.
8
+
9
+ Notes:
10
+ - We intentionally keep this implementation small and dependency-free.
11
+ - Values are rendered as single-line scalars (quoted when needed).
12
+ """
13
+ lines = ["---"]
14
+ for k, v in fields.items():
15
+ if v is None:
16
+ continue
17
+ s = str(v)
18
+ # Quote if it contains colon, newline, or leading/trailing whitespace
19
+ if any(ch in s for ch in [":", "\n"]) or s != s.strip():
20
+ s = s.replace("\\", "\\\\").replace('"', '\\"')
21
+ s = f'"{s}"'
22
+ lines.append(f"{k}: {s}")
23
+ lines.append("---")
24
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Any, Dict
7
+
8
+
9
+ def ensure_dir(path: Path) -> None:
10
+ path.mkdir(parents=True, exist_ok=True)
11
+
12
+
13
+ def read_json(path: Path) -> Dict[str, Any]:
14
+ return json.loads(path.read_text(encoding="utf-8"))
15
+
16
+
17
+ def write_json(path: Path, obj: Any) -> None:
18
+ ensure_dir(path.parent)
19
+ path.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
20
+
21
+
22
+ def copytree(src: Path, dst: Path, *, overwrite: bool = True) -> None:
23
+ if not src.exists():
24
+ return
25
+ if dst.exists() and overwrite:
26
+ shutil.rmtree(dst)
27
+ shutil.copytree(src, dst)
28
+
29
+
30
+ def copyfile(src: Path, dst: Path) -> None:
31
+ ensure_dir(dst.parent)
32
+ shutil.copy2(src, dst)