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.
- anycrew/__init__.py +1 -0
- anycrew/__main__.py +4 -0
- anycrew/cli.py +226 -0
- anycrew/frontmatter.py +111 -0
- anycrew/fsx.py +53 -0
- anycrew/generate.py +304 -0
- anycrew/pack/agents/ai-engineer.md +50 -0
- anycrew/pack/agents/architect.md +45 -0
- anycrew/pack/agents/brainstormer.md +46 -0
- anycrew/pack/agents/crew-lead.md +53 -0
- anycrew/pack/agents/debugger.md +38 -0
- anycrew/pack/agents/designer.md +43 -0
- anycrew/pack/agents/doc-writer.md +45 -0
- anycrew/pack/agents/implementer.md +56 -0
- anycrew/pack/agents/product-manager.md +44 -0
- anycrew/pack/agents/release-engineer.md +45 -0
- anycrew/pack/agents/researcher.md +41 -0
- anycrew/pack/agents/reviewer.md +59 -0
- anycrew/pack/agents/scout.md +50 -0
- anycrew/pack/agents/security-reviewer.md +54 -0
- anycrew/pack/agents/spec-writer.md +43 -0
- anycrew/pack/agents/tester.md +41 -0
- anycrew/pack/hooks/hooks.json +18 -0
- anycrew/pack/hooks/scripts/lint-after-edit.sh +65 -0
- anycrew/pack/models.json +98 -0
- anycrew/pack/package.json +49 -0
- anycrew/pack/presets/generic.json +28 -0
- anycrew/pack/presets/go-service.json +38 -0
- anycrew/pack/presets/node-typescript-next.json +44 -0
- anycrew/pack/presets/python-fastapi-react.json +49 -0
- anycrew/pack/rules/00-sdd-process.md +21 -0
- anycrew/pack/rules/10-architecture-boundaries.md +26 -0
- anycrew/pack/rules/40-security-and-data.md +25 -0
- anycrew/pack/rules/50-llm-features.md +27 -0
- anycrew/pack/rules/60-testing.md +19 -0
- anycrew/pack/rules/70-docs-and-status.md +19 -0
- anycrew/pack/skills/crew-adr/SKILL.md +21 -0
- anycrew/pack/skills/crew-architecture/SKILL.md +24 -0
- anycrew/pack/skills/crew-brainstorm/SKILL.md +24 -0
- anycrew/pack/skills/crew-checkpoint/SKILL.md +22 -0
- anycrew/pack/skills/crew-debug/SKILL.md +22 -0
- anycrew/pack/skills/crew-deploy-check/SKILL.md +28 -0
- anycrew/pack/skills/crew-design/SKILL.md +22 -0
- anycrew/pack/skills/crew-docs/SKILL.md +23 -0
- anycrew/pack/skills/crew-implement/SKILL.md +24 -0
- anycrew/pack/skills/crew-init/SKILL.md +36 -0
- anycrew/pack/skills/crew-llm/SKILL.md +28 -0
- anycrew/pack/skills/crew-plan/SKILL.md +24 -0
- anycrew/pack/skills/crew-prd/SKILL.md +22 -0
- anycrew/pack/skills/crew-research/SKILL.md +21 -0
- anycrew/pack/skills/crew-review/SKILL.md +21 -0
- anycrew/pack/skills/crew-security/SKILL.md +21 -0
- anycrew/pack/skills/crew-spec/SKILL.md +24 -0
- anycrew/pack/skills/crew-status/SKILL.md +31 -0
- anycrew/pack/skills/crew-sync/SKILL.md +23 -0
- anycrew/pack/skills/crew-tasks/SKILL.md +21 -0
- anycrew/pack/skills/crew-test/SKILL.md +21 -0
- anycrew/pack/skills/crew-verify/SKILL.md +23 -0
- anycrew/pack/templates/AGENTS.md +85 -0
- anycrew/pack/templates/CLAUDE.md +3 -0
- anycrew/pack/templates/GEMINI.md +2 -0
- anycrew/pack/templates/anycrew.config.json +13 -0
- anycrew/pack/templates/copilot-instructions.md +6 -0
- anycrew/pack/templates/docs/00-constitution.md +62 -0
- anycrew/pack/templates/docs/01-brainstorm-and-decision.md +45 -0
- anycrew/pack/templates/docs/02-research.md +17 -0
- anycrew/pack/templates/docs/03-prd.md +31 -0
- anycrew/pack/templates/docs/04-requirements-traceability.md +9 -0
- anycrew/pack/templates/docs/05-architecture.md +35 -0
- anycrew/pack/templates/docs/06-agent-design.md +25 -0
- anycrew/pack/templates/docs/09-data-model.md +17 -0
- anycrew/pack/templates/docs/10-api-contract.md +13 -0
- anycrew/pack/templates/docs/11-frontend.md +18 -0
- anycrew/pack/templates/docs/12-design-system.md +14 -0
- anycrew/pack/templates/docs/13-conventions.md +26 -0
- anycrew/pack/templates/docs/14-security.md +23 -0
- anycrew/pack/templates/docs/15-testing.md +18 -0
- anycrew/pack/templates/docs/16-infrastructure.md +17 -0
- anycrew/pack/templates/docs/17-observability.md +8 -0
- anycrew/pack/templates/docs/19-delivery-plan.md +10 -0
- anycrew/pack/templates/docs/20-implementation-status.md +18 -0
- anycrew/pack/templates/docs/README.md +34 -0
- anycrew/pack/templates/docs/adr/ADR-template.md +13 -0
- anycrew/pack/templates/docs/adr/README.md +4 -0
- anycrew/pack/templates/docs/glossary.md +8 -0
- anycrew/pack/templates/docs/specs/NNN-template/plan.md +25 -0
- anycrew/pack/templates/docs/specs/NNN-template/spec.md +26 -0
- anycrew/pack/templates/docs/specs/NNN-template/tasks.md +14 -0
- anycrew/pack/templates/docs/specs/README.md +25 -0
- anycrew/pack.py +89 -0
- anycrew-0.1.0.dist-info/METADATA +282 -0
- anycrew-0.1.0.dist-info/RECORD +95 -0
- anycrew-0.1.0.dist-info/WHEEL +4 -0
- anycrew-0.1.0.dist-info/entry_points.txt +2 -0
- 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
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)
|