ai-forge-cli 0.1.2__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.
cli/commands/init.py ADDED
@@ -0,0 +1,447 @@
1
+ """`forge init` — scaffold a new Forge project in the current directory.
2
+
3
+ Creates the spec directory layout and symlinks the agent skills from the
4
+ forge install into this project's skill-discovery paths so Claude Code,
5
+ VS Code Copilot, and Codex can all find them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import shutil
13
+ import sys
14
+ import tarfile
15
+ import tempfile
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from importlib import metadata
20
+ from pathlib import Path
21
+
22
+ import cli
23
+
24
+ # --- Intro animation (adapted from the hammer-cli forge-fire banner) -------
25
+
26
+ _RESET = "\033[0m"
27
+ _HIDE_CURSOR = "\033[?25l"
28
+ _SHOW_CURSOR = "\033[?25h"
29
+ _ORANGE = 208
30
+ _BANNER_TEXT = "INITIALISING FORGE"
31
+ _SPARKS = [(238, "·"), (166, ":"), (172, "•"), (202, "*"), (_ORANGE, "✦")]
32
+ _FIRE = [160, 166, 172, 178, 184, 220, 214, 208, 202]
33
+
34
+ # Extended palette for init output (reusing fire tones for continuity).
35
+ _FIRE_PRIMARY = 208 # main warm highlight
36
+ _FIRE_SOFT = 220 # lighter amber, section dividers
37
+ _FIRE_DEEP = 166 # deeper red-orange, marker/arrow
38
+ _OK_GREEN = 34 # forest green for ✓ (softer than default bright green)
39
+ _META = 245 # soft gray for paths + meta info
40
+
41
+
42
+ def _styled() -> bool:
43
+ """True when stdout supports ANSI output."""
44
+ import os
45
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
46
+
47
+
48
+ def _color(code: int, text: str) -> str:
49
+ if not _styled():
50
+ return text
51
+ return f"\033[38;5;{code}m{text}{_RESET}"
52
+
53
+
54
+ def _bold(text: str) -> str:
55
+ if not _styled():
56
+ return text
57
+ return f"\033[1m{text}{_RESET}"
58
+
59
+
60
+ def _dim(text: str) -> str:
61
+ if not _styled():
62
+ return text
63
+ return f"\033[2m{text}{_RESET}"
64
+
65
+
66
+ def _divider(label: str) -> str:
67
+ """Section divider: ── label ──, fire-orange colored."""
68
+ bar = "─" * 5
69
+ return _color(_FIRE_DEEP, bar + " ") + _bold(_color(_FIRE_PRIMARY, label)) + _color(_FIRE_DEEP, " " + bar)
70
+
71
+
72
+ def _fire_text(visible: int, shift: int) -> str:
73
+ size = len(_FIRE)
74
+ return "".join(
75
+ _color(_FIRE[(i + shift) % size], ch) if i < visible else " "
76
+ for i, ch in enumerate(_BANNER_TEXT)
77
+ )
78
+
79
+
80
+ def _play_banner() -> None:
81
+ """Ember-sparks → progressive reveal → fire flicker, once."""
82
+ text_len = len(_BANNER_TEXT)
83
+ columns, _lines = shutil.get_terminal_size((80, 24))
84
+ clear = " " * max(columns - 1, 0)
85
+
86
+ def draw(frame: str, delay: float) -> None:
87
+ sys.stdout.write("\r" + clear + "\r" + frame)
88
+ sys.stdout.flush()
89
+ time.sleep(delay)
90
+
91
+ sys.stdout.write(_HIDE_CURSOR)
92
+ try:
93
+ for code, spark in _SPARKS:
94
+ draw(_color(code, spark) + " " + (" " * text_len), 0.07)
95
+ for count in range(1, text_len + 1):
96
+ draw(
97
+ _color(_FIRE[count % len(_FIRE)], "✦") + " " + _fire_text(count, count),
98
+ 0.065 if count < text_len else 0.16,
99
+ )
100
+ for shift in range(len(_FIRE) * 2):
101
+ draw(
102
+ _color(_FIRE[shift % len(_FIRE)], "✦") + " " + _fire_text(text_len, shift),
103
+ 0.08,
104
+ )
105
+ finally:
106
+ sys.stdout.write(_RESET + _SHOW_CURSOR + "\n")
107
+ sys.stdout.flush()
108
+
109
+ NAME = "init"
110
+ HELP = "Scaffold a new Forge project in the current directory."
111
+ DESCRIPTION = (
112
+ "Creates the spec directory layout (.forge/ with L-layer subdirs) "
113
+ "symlinks the 12 schema template files from the forge source into "
114
+ ".forge/templates/ for in-project reference, and symlinks the forge "
115
+ "agent skills into .claude/skills/ (Claude Code), .codex/skills/ "
116
+ "(OpenAI Codex CLI), and .agents/skills/ (agentskills.io clients — "
117
+ "VS Code Copilot, Cursor). Run in a new or empty project directory "
118
+ "to bootstrap."
119
+ )
120
+
121
+ # Skills to symlink. Must match the set installed into the forge source's
122
+ # .agents/skills/ directory.
123
+ SKILL_NAMES = (
124
+ "forge-discover",
125
+ "forge-decompose",
126
+ "forge-atom",
127
+ "forge-compose",
128
+ "forge-audit",
129
+ "forge-armour",
130
+ "forge-implement",
131
+ "forge-test-writer",
132
+ "forge-implementer",
133
+ "forge-validate",
134
+ )
135
+
136
+ # Subdirectories created under the spec dir. Empty at init; populated by
137
+ # subsequent skill runs (discover writes L2_modules; decompose writes L3_atoms;
138
+ # etc.).
139
+ SPEC_SUBDIRS = (
140
+ "L2_modules",
141
+ "L2_policies",
142
+ "L3_atoms",
143
+ "L3_artifacts",
144
+ "L4_flows",
145
+ "L4_journeys",
146
+ )
147
+
148
+ # Markers that indicate an existing forge project (refuse to init over unless --force).
149
+ EXISTING_PROJECT_MARKERS = (
150
+ "discovery-notes.md",
151
+ "L0_registry.yaml",
152
+ "L1_conventions.yaml",
153
+ "L5_operations.yaml",
154
+ )
155
+
156
+ _FORGE_GITHUB_ARCHIVE = "https://codeload.github.com/GreyFlames07/forge/tar.gz/refs/tags"
157
+
158
+
159
+ def _installed_cli_version() -> str | None:
160
+ """Return the installed distribution version for this CLI."""
161
+ for dist_name in ("forge-ai-cli", "forge-cli"):
162
+ try:
163
+ return metadata.version(dist_name)
164
+ except metadata.PackageNotFoundError:
165
+ continue
166
+ return None
167
+
168
+
169
+ def _cache_base_dir() -> Path:
170
+ """Return a writable user cache directory for downloaded Forge assets."""
171
+ if sys.platform == "darwin":
172
+ return Path.home() / "Library" / "Caches" / "forge-ai-cli"
173
+ return Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "forge-ai-cli"
174
+
175
+
176
+ def _cached_forge_repo() -> Path | None:
177
+ """Get Forge assets from cache, downloading from the tagged GitHub archive if needed."""
178
+ version = _installed_cli_version()
179
+ if not version:
180
+ return None
181
+
182
+ tag = f"v{version}"
183
+ cache_root = _cache_base_dir() / tag
184
+ repo_dest = cache_root / "repo"
185
+ skills_dest = repo_dest / ".agents" / "skills"
186
+ templates_dest = repo_dest / "src" / "templates"
187
+ if skills_dest.is_dir() and templates_dest.is_dir():
188
+ return repo_dest
189
+
190
+ cache_root.mkdir(parents=True, exist_ok=True)
191
+ archive_url = f"{_FORGE_GITHUB_ARCHIVE}/{tag}"
192
+
193
+ fd, archive_path_str = tempfile.mkstemp(prefix=f"forge-{tag}-", suffix=".tar.gz")
194
+ os.close(fd)
195
+ archive_path = Path(archive_path_str)
196
+ extract_root = cache_root / "extract"
197
+ try:
198
+ urllib.request.urlretrieve(archive_url, archive_path)
199
+ if extract_root.exists():
200
+ shutil.rmtree(extract_root)
201
+ extract_root.mkdir(parents=True, exist_ok=True)
202
+ with tarfile.open(archive_path, mode="r:gz") as tf:
203
+ extract_root_resolved = extract_root.resolve()
204
+ for member in tf.getmembers():
205
+ member_path = (extract_root / member.name).resolve()
206
+ if not member_path.is_relative_to(extract_root_resolved):
207
+ return None
208
+ tf.extractall(extract_root)
209
+ except (urllib.error.URLError, OSError, tarfile.TarError):
210
+ return None
211
+ finally:
212
+ if archive_path.exists():
213
+ archive_path.unlink()
214
+
215
+ for child in extract_root.iterdir():
216
+ if not child.is_dir():
217
+ continue
218
+ if (child / ".agents" / "skills").is_dir() and (child / "src" / "templates").is_dir():
219
+ if repo_dest.exists():
220
+ shutil.rmtree(repo_dest)
221
+ shutil.move(str(child), str(repo_dest))
222
+ shutil.rmtree(extract_root, ignore_errors=True)
223
+ return repo_dest
224
+
225
+ return None
226
+
227
+
228
+ def _resolve_forge_sources() -> tuple[Path, Path]:
229
+ """Locate the forge repo root and bundled skills directory."""
230
+ cli_dir = Path(cli.__file__).resolve().parent
231
+ forge_repo = cli_dir.parent.parent # src/cli/ -> src/ -> repo root
232
+ skills_src = forge_repo / ".agents" / "skills"
233
+
234
+ if skills_src.is_dir():
235
+ return forge_repo, skills_src
236
+
237
+ cached_repo = _cached_forge_repo()
238
+ if cached_repo is not None:
239
+ cached_skills = cached_repo / ".agents" / "skills"
240
+ if cached_skills.is_dir():
241
+ return cached_repo, cached_skills
242
+
243
+ raise FileNotFoundError(
244
+ f"cannot locate forge skills at {skills_src}\n"
245
+ "If running from source, install in editable mode from the forge repo root:\n"
246
+ " uv pip install -e .\n"
247
+ "If installed via pip, ensure network access for one-time asset bootstrap "
248
+ f"from {_FORGE_GITHUB_ARCHIVE}/v<version>."
249
+ )
250
+
251
+
252
+ def _ensure_spec_structure(project_root: Path, spec_dir: Path, *, ok: str) -> None:
253
+ """Ensure the managed Forge spec layout exists under spec_dir."""
254
+ spec_dir.mkdir(parents=True, exist_ok=True)
255
+ spec_rel = spec_dir.relative_to(project_root)
256
+ print(f" {ok} {_bold(str(spec_rel) + '/')}")
257
+
258
+ for sub in SPEC_SUBDIRS:
259
+ d = spec_dir / sub
260
+ d.mkdir(exist_ok=True)
261
+ gitkeep = d / ".gitkeep"
262
+ if not gitkeep.exists():
263
+ gitkeep.write_text("")
264
+
265
+ print(f" {ok} {len(SPEC_SUBDIRS)} spec subdirectories "
266
+ + _dim("(L2_modules, L2_policies, L3_atoms, L3_artifacts, L4_flows, L4_journeys)"))
267
+
268
+
269
+ def register(sub: argparse._SubParsersAction) -> None:
270
+ p = sub.add_parser(NAME, help=HELP, description=DESCRIPTION)
271
+ p.add_argument(
272
+ "--spec-subdir", default=".forge",
273
+ help="Relative path from project root for the spec directory. Default: .forge",
274
+ )
275
+ p.add_argument(
276
+ "--skip-skills", action="store_true",
277
+ help="Skip skill symlink creation.",
278
+ )
279
+ p.add_argument(
280
+ "--force", action="store_true",
281
+ help="Overwrite existing symlinks and proceed over existing projects.",
282
+ )
283
+ p.add_argument(
284
+ "--no-banner", action="store_true",
285
+ help="Skip the init banner animation.",
286
+ )
287
+ p.set_defaults(handler=run)
288
+
289
+
290
+ def run(args: argparse.Namespace) -> int:
291
+ # Banner animation: only when stdout is a terminal, and only if not suppressed.
292
+ if sys.stdout.isatty() and not args.no_banner:
293
+ _play_banner()
294
+
295
+ project_root = Path.cwd()
296
+ spec_dir = project_root / args.spec_subdir
297
+
298
+ try:
299
+ forge_repo, skills_src = _resolve_forge_sources()
300
+ except FileNotFoundError as e:
301
+ lines = str(e).splitlines()
302
+ print(f"error: {lines[0]}", file=sys.stderr)
303
+ for line in lines[1:]:
304
+ print(line, file=sys.stderr)
305
+ return 1
306
+
307
+ # Refuse to init over an existing project unless --force.
308
+ existing = [spec_dir / m for m in EXISTING_PROJECT_MARKERS]
309
+ if any(p.exists() for p in existing) and not args.force:
310
+ print(f"error: existing forge project detected at {spec_dir}/", file=sys.stderr)
311
+ print("Use --force to init over it (will not overwrite existing spec files).", file=sys.stderr)
312
+ return 1
313
+
314
+ print()
315
+ print(_color(_FIRE_PRIMARY, "▸ ") + _bold("Forge init ") + _dim(f"in {project_root}"))
316
+ print()
317
+
318
+ ok = _color(_OK_GREEN, "✓")
319
+ _ensure_spec_structure(project_root, spec_dir, ok=ok)
320
+
321
+ # Step 2: symlink schema templates into .forge/templates/ for in-project reference.
322
+ _install_schema_templates(spec_dir, forge_repo, force=args.force, ok=ok)
323
+
324
+ # Step 3: symlink skills.
325
+ if not args.skip_skills:
326
+ _install_skill_symlinks(project_root, skills_src, force=args.force, ok=ok)
327
+
328
+ # Step 4: next steps section.
329
+ print()
330
+ print(_divider("Next steps"))
331
+ print()
332
+ print(_dim(" Set the spec dir ") + _dim("(add to your shell rc to persist):"))
333
+ print(f" {_bold('export FORGE_SPEC_DIR=\"' + str(spec_dir) + '\"')}")
334
+ print()
335
+ print(_dim(" Start a session in this directory:"))
336
+ print(f" {_bold(_color(_FIRE_PRIMARY, 'claude'))} "
337
+ + _dim("│ ") + f"{_bold(_color(_FIRE_PRIMARY, 'codex'))} "
338
+ + _dim("│ ") + _dim("any agentskills.io client (VS Code Copilot, Cursor)"))
339
+ print()
340
+ print(_dim(" Trigger a forge skill with a natural-language prompt:"))
341
+ print(f" {_bold('\"I want to build a tool that does X\"')}")
342
+ print(f" {_bold('\"Decompose the PAY module into atoms\"')}")
343
+ print(f" {_bold('\"Compose flows and journeys from completed atoms\"')}")
344
+ print(f" {_bold('\"Audit the specs before implementation\"')}")
345
+ print(f" {_bold('\"Harden the specs for security before implementation\"')}")
346
+ print(f" {_bold('\"Validate the implementation against specs\"')}")
347
+ print()
348
+ print(_dim(" Claude Code also supports slash-command shortcuts:"))
349
+ print(f" {_color(_FIRE_PRIMARY, '/forge-discover')} "
350
+ + _color(_FIRE_PRIMARY, "/forge-decompose") + " "
351
+ + _color(_FIRE_PRIMARY, "/forge-atom") + " "
352
+ + _color(_FIRE_PRIMARY, "/forge-compose") + " "
353
+ + _color(_FIRE_PRIMARY, "/forge-audit") + " "
354
+ + _color(_FIRE_PRIMARY, "/forge-armour") + " "
355
+ + _color(_FIRE_PRIMARY, "/forge-implement") + " "
356
+ + _color(_FIRE_PRIMARY, "/forge-validate"))
357
+ print()
358
+
359
+ return 0
360
+
361
+
362
+ def _install_schema_templates(spec_dir: Path, forge_repo: Path, *, force: bool, ok: str) -> None:
363
+ """Symlink every schema/guide template from src/templates/L*/ into
364
+ <spec_dir>/templates/ (flat). Templates travel with the forge version —
365
+ broken symlinks after a forge repo move are a clear signal to re-run
366
+ `forge init --force`.
367
+ """
368
+ templates_src = forge_repo / "src" / "templates"
369
+ templates_dest = spec_dir / "templates"
370
+
371
+ if not templates_src.is_dir():
372
+ print(f" {_color(160, '✗')} templates source missing at {templates_src}; skipping")
373
+ return
374
+
375
+ templates_dest.mkdir(exist_ok=True)
376
+
377
+ linked = 0
378
+ skipped = 0
379
+ for layer_dir in sorted(templates_src.iterdir()):
380
+ if not layer_dir.is_dir():
381
+ continue
382
+ for template in sorted(layer_dir.iterdir()):
383
+ if template.suffix != ".md":
384
+ continue
385
+ dest = templates_dest / template.name
386
+ if dest.is_symlink() or dest.exists():
387
+ if force and (dest.is_symlink() or dest.is_file()):
388
+ dest.unlink()
389
+ else:
390
+ skipped += 1
391
+ continue
392
+ dest.symlink_to(template.resolve())
393
+ linked += 1
394
+
395
+ templates_rel = templates_dest.relative_to(spec_dir.parent)
396
+ print(f" {ok} {linked} schema templates " + _dim(f"→ {templates_rel}/"))
397
+ if skipped:
398
+ print(_dim(f" ({skipped} already present; use --force to recreate)"))
399
+
400
+
401
+ def _install_skill_symlinks(project_root: Path, skills_src: Path, *, force: bool, ok: str) -> None:
402
+ """Symlink every known skill into the discovery paths used by Claude Code,
403
+ Codex CLI, and agentskills.io clients (Copilot, Cursor, VS Code).
404
+ """
405
+ dest_parents = [
406
+ project_root / ".claude" / "skills", # Claude Code
407
+ project_root / ".codex" / "skills", # OpenAI Codex CLI
408
+ project_root / ".agents" / "skills", # agentskills.io (Copilot, Cursor, ...)
409
+ ]
410
+ for parent in dest_parents:
411
+ parent.mkdir(parents=True, exist_ok=True)
412
+
413
+ linked = 0
414
+ skipped = 0
415
+ missing = 0
416
+
417
+ for name in SKILL_NAMES:
418
+ src = skills_src / name
419
+ if not src.is_dir():
420
+ missing += 1
421
+ print(f" {_color(160, '✗')} skill source missing: {src}")
422
+ continue
423
+
424
+ for dest_parent in dest_parents:
425
+ dest = dest_parent / name
426
+ if dest.is_symlink() or dest.exists():
427
+ if force:
428
+ if dest.is_symlink() or dest.is_file():
429
+ dest.unlink()
430
+ else:
431
+ # Directory exists (not a symlink); refuse to remove.
432
+ skipped += 1
433
+ print(_dim(f" - {dest} is a real directory; not touching"))
434
+ continue
435
+ else:
436
+ skipped += 1
437
+ continue
438
+ dest.symlink_to(src)
439
+ linked += 1
440
+
441
+ total_attempted = len(SKILL_NAMES) * len(dest_parents)
442
+ print(f" {ok} {linked}/{total_attempted} skill symlinks "
443
+ + _dim("→ .claude/skills/, .codex/skills/, .agents/skills/"))
444
+ if skipped:
445
+ print(_dim(f" ({skipped} already present; use --force to recreate)"))
446
+ if missing:
447
+ print(_dim(f" ({missing} skills missing from source)"))
@@ -0,0 +1,111 @@
1
+ """`forge inspect <id>` — lightweight metadata probe for any id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections import OrderedDict
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from cli import common
13
+
14
+ NAME = "inspect"
15
+ HELP = "Show lightweight metadata for an id (no full bundle expansion)."
16
+ DESCRIPTION = (
17
+ "Prints kind, file path, description, owner, and direct "
18
+ "dependencies for <id>. Useful for quick 'does this exist and "
19
+ "what is it?' probes before calling `forge context`."
20
+ )
21
+
22
+
23
+ def register(sub: argparse._SubParsersAction) -> None:
24
+ p = sub.add_parser(NAME, help=HELP, description=DESCRIPTION)
25
+ p.add_argument("id", help="Any id present in the spec directory.")
26
+ common.add_spec_dir_arg(p)
27
+ p.set_defaults(handler=run)
28
+
29
+
30
+ def run(args: argparse.Namespace) -> int:
31
+ idx, rc = common.load_index(args.spec_dir)
32
+ if rc != 0:
33
+ return rc
34
+
35
+ entry = idx.get(args.id)
36
+ if entry is None:
37
+ print(f"error: unknown id: {args.id}", file=sys.stderr)
38
+ common.suggest_similar(idx, args.id)
39
+ return 1
40
+
41
+ info: OrderedDict[str, Any] = OrderedDict()
42
+ info["id"] = entry.id
43
+ info["kind"] = entry.kind
44
+ if entry.file:
45
+ info["file"] = (
46
+ str(entry.file.relative_to(idx.spec_dir))
47
+ if entry.file.is_relative_to(idx.spec_dir)
48
+ else str(entry.file)
49
+ )
50
+ desc = common.full_description(entry.data)
51
+ if desc:
52
+ info["description"] = desc
53
+
54
+ data = entry.data if isinstance(entry.data, dict) else {}
55
+ _populate_kind_extras(info, entry.kind, data)
56
+
57
+ if entry.kind in common.BUNDLEABLE_KINDS:
58
+ info["bundleable"] = True
59
+ info["bundle_command"] = f"forge context {entry.id}"
60
+ else:
61
+ info["bundleable"] = False
62
+
63
+ sys.stdout.write(yaml.dump(
64
+ dict(info), sort_keys=False, allow_unicode=True,
65
+ default_flow_style=False, width=100,
66
+ ))
67
+ return 0
68
+
69
+
70
+ def _populate_kind_extras(info: OrderedDict[str, Any], kind: str, data: dict[str, Any]) -> None:
71
+ """Add per-kind useful metadata to the inspect output."""
72
+ if kind == "atom":
73
+ spec = data.get("spec") or {}
74
+ info["atom_kind"] = data.get("kind")
75
+ info["owner_module"] = data.get("owner_module")
76
+ info["side_effects"] = spec.get("side_effects") or []
77
+ info["output_errors"] = (spec.get("output") or {}).get("errors") or []
78
+ elif kind == "module":
79
+ info["owned_atoms"] = data.get("owned_atoms") or []
80
+ info["owned_artifacts"] = data.get("owned_artifacts") or []
81
+ info["dependency_whitelist"] = (data.get("dependency_whitelist") or {}).get("modules") or []
82
+ info["policies"] = data.get("policies") or []
83
+ elif kind == "flow":
84
+ info["transaction_boundary"] = data.get("transaction_boundary")
85
+ info["trigger"] = data.get("trigger")
86
+ info["steps"] = [s.get("step") for s in data.get("sequence") or []]
87
+ elif kind == "journey":
88
+ info["surface"] = data.get("surface")
89
+ info["states"] = data.get("states") or []
90
+ info["exit_states"] = data.get("exit_states") or []
91
+ elif kind == "artifact":
92
+ info["owner_module"] = data.get("owner_module")
93
+ info["format"] = data.get("format")
94
+ info["produced_by"] = (data.get("provenance") or {}).get("produced_by")
95
+ info["consumers"] = data.get("consumers") or []
96
+ elif kind == "error":
97
+ info["category"] = data.get("category")
98
+ info["message"] = data.get("message")
99
+ elif kind == "type":
100
+ info["type_kind"] = data.get("kind")
101
+ if data.get("kind") == "entity":
102
+ info["fields"] = list((data.get("fields") or {}).keys())
103
+ elif data.get("kind") == "enum":
104
+ info["values"] = data.get("values")
105
+ elif kind == "constant":
106
+ info["type"] = data.get("type")
107
+ info["value"] = data.get("value")
108
+ elif kind == "external_schema":
109
+ info["provider"] = data.get("provider")
110
+ info["base_url"] = data.get("base_url")
111
+ info["auth_method"] = data.get("auth_method")
@@ -0,0 +1,72 @@
1
+ """`forge list [--kind]` — enumerate ids in the spec directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from cli import common
8
+ from cli import index as index_mod
9
+
10
+ NAME = "list"
11
+ HELP = "Enumerate ids present in the spec directory."
12
+ DESCRIPTION = (
13
+ "Lists every id in the spec dir, optionally filtered by kind. "
14
+ "With no --kind, groups output by kind."
15
+ )
16
+
17
+
18
+ def register(sub: argparse._SubParsersAction) -> None:
19
+ p = sub.add_parser(NAME, help=HELP, description=DESCRIPTION)
20
+ p.add_argument(
21
+ "--kind", choices=common.ALL_KINDS, default=None,
22
+ help="Restrict output to a single kind.",
23
+ )
24
+ common.add_spec_dir_arg(p)
25
+ p.add_argument(
26
+ "--ids-only", action="store_true",
27
+ help="Emit just the ids, one per line — suitable for piping.",
28
+ )
29
+ p.set_defaults(handler=run)
30
+
31
+
32
+ def run(args: argparse.Namespace) -> int:
33
+ idx, rc = common.load_index(args.spec_dir)
34
+ if rc != 0:
35
+ return rc
36
+
37
+ if args.kind:
38
+ entries = sorted(idx.by_kind(args.kind), key=lambda e: e.id)
39
+ if args.ids_only:
40
+ for e in entries:
41
+ print(e.id)
42
+ else:
43
+ print(f"# {args.kind} ({len(entries)})")
44
+ for e in entries:
45
+ desc = common.one_line_description(e.data)
46
+ suffix = f" — {desc}" if desc else ""
47
+ print(f" {e.id}{suffix}")
48
+ return 0
49
+
50
+ grouped: dict[str, list[index_mod.Entry]] = {}
51
+ for e in idx.entries.values():
52
+ grouped.setdefault(e.kind, []).append(e)
53
+
54
+ if args.ids_only:
55
+ for kind in common.ALL_KINDS:
56
+ for e in sorted(grouped.get(kind, []), key=lambda x: x.id):
57
+ print(e.id)
58
+ return 0
59
+
60
+ print(f"# Spec dir: {idx.spec_dir}")
61
+ print(f"# Total entries: {sum(len(v) for v in grouped.values())}")
62
+ for kind in common.ALL_KINDS:
63
+ entries = sorted(grouped.get(kind, []), key=lambda x: x.id)
64
+ if not entries:
65
+ continue
66
+ print()
67
+ print(f"# {kind} ({len(entries)})")
68
+ for e in entries:
69
+ desc = common.one_line_description(e.data)
70
+ suffix = f" — {desc}" if desc else ""
71
+ print(f" {e.id}{suffix}")
72
+ return 0