anycrew 0.1.0__py3-none-any.whl

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 (95) hide show
  1. anycrew/__init__.py +1 -0
  2. anycrew/__main__.py +4 -0
  3. anycrew/cli.py +226 -0
  4. anycrew/frontmatter.py +111 -0
  5. anycrew/fsx.py +53 -0
  6. anycrew/generate.py +304 -0
  7. anycrew/pack/agents/ai-engineer.md +50 -0
  8. anycrew/pack/agents/architect.md +45 -0
  9. anycrew/pack/agents/brainstormer.md +46 -0
  10. anycrew/pack/agents/crew-lead.md +53 -0
  11. anycrew/pack/agents/debugger.md +38 -0
  12. anycrew/pack/agents/designer.md +43 -0
  13. anycrew/pack/agents/doc-writer.md +45 -0
  14. anycrew/pack/agents/implementer.md +56 -0
  15. anycrew/pack/agents/product-manager.md +44 -0
  16. anycrew/pack/agents/release-engineer.md +45 -0
  17. anycrew/pack/agents/researcher.md +41 -0
  18. anycrew/pack/agents/reviewer.md +59 -0
  19. anycrew/pack/agents/scout.md +50 -0
  20. anycrew/pack/agents/security-reviewer.md +54 -0
  21. anycrew/pack/agents/spec-writer.md +43 -0
  22. anycrew/pack/agents/tester.md +41 -0
  23. anycrew/pack/hooks/hooks.json +18 -0
  24. anycrew/pack/hooks/scripts/lint-after-edit.sh +65 -0
  25. anycrew/pack/models.json +98 -0
  26. anycrew/pack/package.json +49 -0
  27. anycrew/pack/presets/generic.json +28 -0
  28. anycrew/pack/presets/go-service.json +38 -0
  29. anycrew/pack/presets/node-typescript-next.json +44 -0
  30. anycrew/pack/presets/python-fastapi-react.json +49 -0
  31. anycrew/pack/rules/00-sdd-process.md +21 -0
  32. anycrew/pack/rules/10-architecture-boundaries.md +26 -0
  33. anycrew/pack/rules/40-security-and-data.md +25 -0
  34. anycrew/pack/rules/50-llm-features.md +27 -0
  35. anycrew/pack/rules/60-testing.md +19 -0
  36. anycrew/pack/rules/70-docs-and-status.md +19 -0
  37. anycrew/pack/skills/crew-adr/SKILL.md +21 -0
  38. anycrew/pack/skills/crew-architecture/SKILL.md +24 -0
  39. anycrew/pack/skills/crew-brainstorm/SKILL.md +24 -0
  40. anycrew/pack/skills/crew-checkpoint/SKILL.md +22 -0
  41. anycrew/pack/skills/crew-debug/SKILL.md +22 -0
  42. anycrew/pack/skills/crew-deploy-check/SKILL.md +28 -0
  43. anycrew/pack/skills/crew-design/SKILL.md +22 -0
  44. anycrew/pack/skills/crew-docs/SKILL.md +23 -0
  45. anycrew/pack/skills/crew-implement/SKILL.md +24 -0
  46. anycrew/pack/skills/crew-init/SKILL.md +36 -0
  47. anycrew/pack/skills/crew-llm/SKILL.md +28 -0
  48. anycrew/pack/skills/crew-plan/SKILL.md +24 -0
  49. anycrew/pack/skills/crew-prd/SKILL.md +22 -0
  50. anycrew/pack/skills/crew-research/SKILL.md +21 -0
  51. anycrew/pack/skills/crew-review/SKILL.md +21 -0
  52. anycrew/pack/skills/crew-security/SKILL.md +21 -0
  53. anycrew/pack/skills/crew-spec/SKILL.md +24 -0
  54. anycrew/pack/skills/crew-status/SKILL.md +31 -0
  55. anycrew/pack/skills/crew-sync/SKILL.md +23 -0
  56. anycrew/pack/skills/crew-tasks/SKILL.md +21 -0
  57. anycrew/pack/skills/crew-test/SKILL.md +21 -0
  58. anycrew/pack/skills/crew-verify/SKILL.md +23 -0
  59. anycrew/pack/templates/AGENTS.md +85 -0
  60. anycrew/pack/templates/CLAUDE.md +3 -0
  61. anycrew/pack/templates/GEMINI.md +2 -0
  62. anycrew/pack/templates/anycrew.config.json +13 -0
  63. anycrew/pack/templates/copilot-instructions.md +6 -0
  64. anycrew/pack/templates/docs/00-constitution.md +62 -0
  65. anycrew/pack/templates/docs/01-brainstorm-and-decision.md +45 -0
  66. anycrew/pack/templates/docs/02-research.md +17 -0
  67. anycrew/pack/templates/docs/03-prd.md +31 -0
  68. anycrew/pack/templates/docs/04-requirements-traceability.md +9 -0
  69. anycrew/pack/templates/docs/05-architecture.md +35 -0
  70. anycrew/pack/templates/docs/06-agent-design.md +25 -0
  71. anycrew/pack/templates/docs/09-data-model.md +17 -0
  72. anycrew/pack/templates/docs/10-api-contract.md +13 -0
  73. anycrew/pack/templates/docs/11-frontend.md +18 -0
  74. anycrew/pack/templates/docs/12-design-system.md +14 -0
  75. anycrew/pack/templates/docs/13-conventions.md +26 -0
  76. anycrew/pack/templates/docs/14-security.md +23 -0
  77. anycrew/pack/templates/docs/15-testing.md +18 -0
  78. anycrew/pack/templates/docs/16-infrastructure.md +17 -0
  79. anycrew/pack/templates/docs/17-observability.md +8 -0
  80. anycrew/pack/templates/docs/19-delivery-plan.md +10 -0
  81. anycrew/pack/templates/docs/20-implementation-status.md +18 -0
  82. anycrew/pack/templates/docs/README.md +34 -0
  83. anycrew/pack/templates/docs/adr/ADR-template.md +13 -0
  84. anycrew/pack/templates/docs/adr/README.md +4 -0
  85. anycrew/pack/templates/docs/glossary.md +8 -0
  86. anycrew/pack/templates/docs/specs/NNN-template/plan.md +25 -0
  87. anycrew/pack/templates/docs/specs/NNN-template/spec.md +26 -0
  88. anycrew/pack/templates/docs/specs/NNN-template/tasks.md +14 -0
  89. anycrew/pack/templates/docs/specs/README.md +25 -0
  90. anycrew/pack.py +89 -0
  91. anycrew-0.1.0.dist-info/METADATA +282 -0
  92. anycrew-0.1.0.dist-info/RECORD +95 -0
  93. anycrew-0.1.0.dist-info/WHEEL +4 -0
  94. anycrew-0.1.0.dist-info/entry_points.txt +2 -0
  95. anycrew-0.1.0.dist-info/licenses/LICENSE +21 -0
anycrew/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """anycrew: one crew of specialist agents for any coding assistant."""
anycrew/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
anycrew/cli.py ADDED
@@ -0,0 +1,226 @@
1
+ """anycrew command line: init, sync, doctor, list, presets. A port of lib/cli.mjs."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .fsx import Report
10
+ from .generate import generate, scaffold_docs
11
+ from .pack import TIERS, TOOLS, load_pack, load_preset, model_for
12
+
13
+ CONFIG_REL = Path(".anycrew") / "config.json"
14
+ HELP = f"""anycrew — one crew of specialist agents for any coding assistant
15
+
16
+ Usage:
17
+ anycrew init [--preset <id|file>] [--tools a,b|all] [--name <project>] [--purpose "<text>"]
18
+ [--dir <path>] [--all-docs] [--force]
19
+ anycrew sync [--tools a,b|all] [--dir <path>] [--force]
20
+ anycrew doctor [--dir <path>]
21
+ anycrew list [--dir <path>]
22
+ anycrew presets
23
+ anycrew --version | --help
24
+
25
+ Tools: {', '.join(TOOLS)} (default: the assistants detected on this machine or in the repo)
26
+ Presets: see `anycrew presets`.
27
+ """
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ argv = sys.argv[1:] if argv is None else argv
32
+ cmd, opts = _parse_args(argv)
33
+ try:
34
+ if opts.get("version"):
35
+ print(load_pack().version)
36
+ return 0
37
+ if not cmd or opts.get("help") or cmd == "help":
38
+ print(HELP)
39
+ return 0
40
+ project_dir = Path(opts.get("dir") or Path.cwd()).resolve()
41
+ if cmd == "init":
42
+ return _init(project_dir, opts)
43
+ if cmd == "sync":
44
+ return _sync(project_dir, opts)
45
+ if cmd == "doctor":
46
+ return _doctor(project_dir)
47
+ if cmd == "list":
48
+ return _list(project_dir)
49
+ if cmd == "presets":
50
+ return _presets()
51
+ print(f'unknown command "{cmd}"\n{HELP}', file=sys.stderr)
52
+ return 2
53
+ except SystemExit as e:
54
+ if isinstance(e.code, str):
55
+ print(e.code, file=sys.stderr)
56
+ return 1
57
+ raise
58
+
59
+
60
+ def _parse_args(argv):
61
+ opts, cmd = {}, None
62
+ i = 0
63
+ while i < len(argv):
64
+ a = argv[i]
65
+ if a.startswith("--"):
66
+ k = "".join(p.capitalize() if j else p for j, p in enumerate(a[2:].split("-")))
67
+ nxt = argv[i + 1] if i + 1 < len(argv) else None
68
+ if nxt is not None and not nxt.startswith("--"):
69
+ opts[k] = nxt
70
+ i += 1
71
+ else:
72
+ opts[k] = True
73
+ elif cmd is None:
74
+ cmd = a
75
+ i += 1
76
+ return cmd, opts
77
+
78
+
79
+ def _read_config(project_dir: Path):
80
+ p = project_dir / CONFIG_REL
81
+ return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
82
+
83
+
84
+ def _detect(project_dir: Path):
85
+ has = lambda b: shutil.which(b) is not None # noqa: E731
86
+ d = lambda p: (project_dir / p).exists() # noqa: E731
87
+ return {
88
+ "claude": {"cli": has("claude"), "dir": d(".claude") or d("CLAUDE.md"), "reads": "AGENTS.md/CLAUDE.md, .claude/{agents,skills,rules}, .claude/settings.json hooks"},
89
+ "codex": {"cli": has("codex"), "dir": d(".codex"), "reads": "AGENTS.md, .agents/skills, .codex/agents/*.toml"},
90
+ "antigravity": {"cli": has("agy"), "dir": d(".agents/agents") or d(".agents/rules"), "reads": "AGENTS.md, .agents/{rules,skills,agents}, .agents/hooks.json"},
91
+ "cursor": {"cli": has("cursor") or has("cursor-agent"), "dir": d(".cursor"), "reads": "AGENTS.md, .cursor/{rules,agents}, .agents/skills, .cursor/hooks.json"},
92
+ "opencode": {"cli": has("opencode"), "dir": d(".opencode"), "reads": "AGENTS.md, .agents/skills, .opencode/agents"},
93
+ "copilot": {"cli": has("copilot"), "dir": d(".github/copilot-instructions.md") or d(".github/agents"), "reads": ".github/copilot-instructions.md, .github/{instructions,agents,hooks}, .agents/skills"},
94
+ }
95
+
96
+
97
+ def _resolve_tools(opt, project_dir: Path) -> list[str]:
98
+ if opt == "all":
99
+ return list(TOOLS)
100
+ if isinstance(opt, str):
101
+ lst = [s.strip() for s in opt.split(",") if s.strip()]
102
+ bad = [t for t in lst if t not in TOOLS]
103
+ if bad:
104
+ raise SystemExit(f"anycrew: unknown tool(s): {', '.join(bad)} (known: {', '.join(TOOLS)})")
105
+ return lst
106
+ d = _detect(project_dir)
107
+ found = [t for t in TOOLS if d[t]["cli"] or d[t]["dir"]]
108
+ return found or ["claude", "codex", "antigravity"]
109
+
110
+
111
+ def _init(project_dir: Path, opts) -> int:
112
+ pack = load_pack()
113
+ preset = load_preset(pack, opts.get("preset"))
114
+ existing = _read_config(project_dir) or {}
115
+ tools = _resolve_tools(opts.get("tools"), project_dir)
116
+ config = {
117
+ "$schema": "https://raw.githubusercontent.com/fadlytanjung/anycrew/main/schemas/config.schema.json",
118
+ "version": 1,
119
+ "anycrew": pack.version,
120
+ "project": opts.get("name") or existing.get("project") or project_dir.name,
121
+ "purpose": opts.get("purpose") if opts.get("purpose") is not None else existing.get("purpose", ""),
122
+ "preset": preset["id"],
123
+ "tools": tools,
124
+ "commands": existing.get("commands") or dict(preset["commands"]),
125
+ "models": existing.get("models", {}),
126
+ "agents": existing.get("agents", {"exclude": []}),
127
+ "skills": existing.get("skills", {"exclude": []}),
128
+ "hooks": existing.get("hooks", {"lint_after_edit": preset.get("hooks", {}).get("lint_after_edit", True) is not False}),
129
+ }
130
+ (project_dir / ".anycrew").mkdir(parents=True, exist_ok=True)
131
+ (project_dir / CONFIG_REL).write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
132
+ report = Report()
133
+ report.written.append(str(project_dir / CONFIG_REL))
134
+ scaffold_docs(pack, config, preset, project_dir, report, all_docs=bool(opts.get("allDocs")))
135
+ generate(pack, config, preset, project_dir, tools, bool(opts.get("force")), report)
136
+ _print(report, project_dir)
137
+ print(f'\nInitialised {config["project"]} with preset "{preset["id"]}" for: {", ".join(tools)}.')
138
+ print("Next: open your assistant in this folder and run /crew-status (or /crew-brainstorm for a new idea).")
139
+ return 0
140
+
141
+
142
+ def _sync(project_dir: Path, opts) -> int:
143
+ pack = load_pack()
144
+ config = _read_config(project_dir)
145
+ if not config:
146
+ raise SystemExit(f"anycrew: no {CONFIG_REL} here; run `anycrew init` first")
147
+ preset = load_preset(pack, config["preset"])
148
+ tools = _resolve_tools(opts["tools"], project_dir) if opts.get("tools") else config["tools"]
149
+ report = Report()
150
+ generate(pack, config, preset, project_dir, tools, bool(opts.get("force")), report)
151
+ _print(report, project_dir)
152
+ return 0
153
+
154
+
155
+ def _print(report: Report, project_dir: Path) -> None:
156
+ def rel(p: str) -> str:
157
+ try:
158
+ return str(Path(p).relative_to(project_dir)) or p
159
+ except ValueError:
160
+ return p
161
+
162
+ if report.written:
163
+ print(f"Written ({len(report.written)}):")
164
+ for p in report.written:
165
+ print(f" + {rel(p)}")
166
+ if report.unchanged:
167
+ print(f"Unchanged: {len(report.unchanged)} file(s)")
168
+ if report.skipped:
169
+ print("Skipped:")
170
+ for p, why in report.skipped:
171
+ print(f" ! {rel(p)} — {why}")
172
+ for n in report.notes:
173
+ print(f" · {n}")
174
+
175
+
176
+ def _doctor(project_dir: Path) -> int:
177
+ pack = load_pack()
178
+ config = _read_config(project_dir)
179
+ d = _detect(project_dir)
180
+ print(f"anycrew {pack.version} · project: {project_dir}")
181
+ print(f"config: {CONFIG_REL} (preset {config['preset']}; tools {', '.join(config['tools'])})" if config else "config: none — run `anycrew init`")
182
+ print("\nAssistant CLI Files in repo Reads")
183
+ for t in TOOLS:
184
+ print(f"{t:<14} {('yes' if d[t]['cli'] else 'no'):<6} {('yes' if d[t]['dir'] else 'no'):<15} {d[t]['reads']}")
185
+ checks = [("AGENTS.md", "AGENTS.md"), ("docs/00-constitution.md", "docs/00-constitution.md"),
186
+ ("docs/20-implementation-status.md", "docs/20-implementation-status.md"),
187
+ (".agents/skills", ".agents/skills"), (".anycrew/hooks/lint-after-edit.sh", ".anycrew/hooks/lint-after-edit.sh")]
188
+ print("\nProject files")
189
+ for name, p in checks:
190
+ print(f" {'✔' if (project_dir / p).exists() else '✘'} {name}")
191
+ if config and config.get("commands", {}).get("check"):
192
+ print(f"\nCheck command: {config['commands']['check']}")
193
+ return 0
194
+
195
+
196
+ def _list(project_dir: Path) -> int:
197
+ pack = load_pack()
198
+ config = _read_config(project_dir) or {}
199
+ over = config.get("models", {})
200
+
201
+ def cell(tool, tier):
202
+ m = model_for(pack.models, over, tool, tier)
203
+ if m is None or m == "":
204
+ return "inherit"
205
+ if isinstance(m, dict):
206
+ return "/".join(x for x in [m.get("model"), m.get("effort") or m.get("model_reasoning_effort")] if x) or "inherit"
207
+ return str(m)
208
+
209
+ head = ["agent", "tier", *TOOLS]
210
+ rows = [[a.id, a.data["tier"], *[cell(t, a.data["tier"]) for t in TOOLS]] for a in pack.agents]
211
+ widths = [max(len(h), *(len(str(r[i])) for r in rows)) for i, h in enumerate(head)]
212
+ line = lambda r: " ".join(str(c).ljust(widths[i]) for i, c in enumerate(r)) # noqa: E731
213
+ print(line(head))
214
+ print(" ".join("-" * w for w in widths))
215
+ for r in rows:
216
+ print(line(r))
217
+ print("\nTiers: " + "\n ".join(f"{t} = {pack.models['tiers'][t]['purpose']}" for t in TIERS))
218
+ print(f"Skills ({len(pack.skills)}): " + " ".join("/" + s.id for s in pack.skills))
219
+ return 0
220
+
221
+
222
+ def _presets() -> int:
223
+ pack = load_pack()
224
+ for p in pack.presets.values():
225
+ print(f"{p['id']:<24} {p['name']}\n{'':<24} {p['description']}")
226
+ return 0
anycrew/frontmatter.py ADDED
@@ -0,0 +1,111 @@
1
+ """Minimal YAML front matter reader/writer, a line-for-line port of lib/frontmatter.mjs.
2
+
3
+ Supports scalars, quoted strings, inline arrays ([a, b]), block lists (- item) and one level of
4
+ nested maps. That is all anycrew's own files use; it is not a general YAML parser.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from typing import Any
11
+
12
+ _FM = re.compile(r"^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$")
13
+ _KV = re.compile(r"^([A-Za-z0-9_-]+):\s*(.*)$")
14
+ _SUB = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.*)$")
15
+
16
+
17
+ def parse(text: str) -> tuple[dict[str, Any], str]:
18
+ m = _FM.match(text)
19
+ if not m:
20
+ return {}, text
21
+ data: dict[str, Any] = {}
22
+ lines = m.group(1).split("\n")
23
+ lines = [l.rstrip("\r") for l in lines]
24
+ i = 0
25
+ while i < len(lines):
26
+ line = lines[i]
27
+ if not line.strip() or line.strip().startswith("#"):
28
+ i += 1
29
+ continue
30
+ kv = _KV.match(line)
31
+ if not kv:
32
+ i += 1
33
+ continue
34
+ key, raw = kv.group(1), kv.group(2).strip()
35
+ if raw in ("", "|", ">-", ">", "|-"):
36
+ block: list[str] = []
37
+ i += 1
38
+ while i < len(lines) and (re.match(r"^\s+\S", lines[i]) or lines[i].strip() == ""):
39
+ block.append(lines[i])
40
+ i += 1
41
+ non_empty = [l for l in block if l.strip()]
42
+ if raw == "" and all(re.match(r"^\s*-\s", l) for l in non_empty):
43
+ data[key] = [scalar(re.sub(r"^\s*-\s*", "", l)) for l in non_empty]
44
+ elif raw == "" and all(_SUB.match(l) for l in non_empty):
45
+ obj: dict[str, Any] = {}
46
+ for l in non_empty:
47
+ s = _SUB.match(l)
48
+ obj[s.group(1)] = scalar(s.group(2))
49
+ data[key] = obj
50
+ else:
51
+ joined = [l.strip() for l in non_empty]
52
+ data[key] = "\n".join(joined) if raw.startswith("|") else " ".join(joined)
53
+ continue
54
+ data[key] = scalar(raw)
55
+ i += 1
56
+ return data, m.group(2)
57
+
58
+
59
+ def scalar(raw: str) -> Any:
60
+ raw = raw.strip()
61
+ if re.match(r"^\[.*\]$", raw):
62
+ inner = raw[1:-1].strip()
63
+ return [scalar(s) for s in inner.split(",")] if inner else []
64
+ if re.match(r'^"(.*)"$', raw):
65
+ return raw[1:-1].replace('\\"', '"')
66
+ if re.match(r"^'(.*)'$", raw):
67
+ return raw[1:-1].replace("''", "'")
68
+ if raw == "true":
69
+ return True
70
+ if raw == "false":
71
+ return False
72
+ if raw in ("null", "~"):
73
+ return None
74
+ return raw
75
+
76
+
77
+ def stringify(data: dict[str, Any], body: str = "") -> str:
78
+ out = ["---"]
79
+ for k, v in data.items():
80
+ if v is None or v == "":
81
+ continue
82
+ if isinstance(v, list):
83
+ if not v:
84
+ continue
85
+ out.append(f"{k}:")
86
+ for item in v:
87
+ out.append(f" - {_q(item)}")
88
+ elif isinstance(v, dict):
89
+ out.append(f"{k}:")
90
+ for sk, sv in v.items():
91
+ out.append(f" {sk}: {_q(sv)}")
92
+ else:
93
+ out.append(f"{k}: {_q(v)}")
94
+ out.append("---")
95
+ body = body[1:] if body.startswith("\n") else body
96
+ return "\n".join(out) + "\n" + body
97
+
98
+
99
+ def _q(v: Any) -> str:
100
+ if isinstance(v, bool):
101
+ return "true" if v else "false"
102
+ if isinstance(v, (int, float)):
103
+ return str(v)
104
+ s = str(v)
105
+ if (
106
+ re.match(r"^[A-Za-z0-9_./*-][^:#\"'\n]*$", s)
107
+ and not re.search(r"\s{2,}", s)
108
+ and s not in ("true", "false", "null", "~")
109
+ ):
110
+ return s
111
+ return json.dumps(s, ensure_ascii=False)
anycrew/fsx.py ADDED
@@ -0,0 +1,53 @@
1
+ """File helpers: generated-file marker, protected writes, reports."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ MARKER = "anycrew:generated"
9
+
10
+
11
+ @dataclass
12
+ class Report:
13
+ written: list[str] = field(default_factory=list)
14
+ unchanged: list[str] = field(default_factory=list)
15
+ skipped: list[tuple[str, str]] = field(default_factory=list)
16
+ notes: list[str] = field(default_factory=list)
17
+
18
+
19
+ def read_if(p: Path) -> str | None:
20
+ try:
21
+ return p.read_text(encoding="utf-8")
22
+ except (FileNotFoundError, NotADirectoryError):
23
+ return None
24
+
25
+
26
+ def write_generated(p: Path, content: str, report: Report, force: bool = False) -> bool:
27
+ """Write a generated file. Refuses to overwrite a file that lacks the marker unless force."""
28
+ current = read_if(p)
29
+ if current is not None and MARKER not in current and not force:
30
+ report.skipped.append((str(p), "exists and is not anycrew-generated (use --force)"))
31
+ return False
32
+ if current == content:
33
+ report.unchanged.append(str(p))
34
+ return True
35
+ p.parent.mkdir(parents=True, exist_ok=True)
36
+ p.write_text(content, encoding="utf-8")
37
+ report.written.append(str(p))
38
+ return True
39
+
40
+
41
+ def write_if_missing(p: Path, content: str, report: Report) -> bool:
42
+ """Write a file only when it does not exist yet (hand-maintained docs)."""
43
+ if p.exists():
44
+ report.unchanged.append(str(p))
45
+ return False
46
+ p.parent.mkdir(parents=True, exist_ok=True)
47
+ p.write_text(content, encoding="utf-8")
48
+ report.written.append(str(p))
49
+ return True
50
+
51
+
52
+ def chmod_x(p: Path) -> None:
53
+ os.chmod(p, 0o755)