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.
- ai_forge_cli-0.1.2.dist-info/METADATA +8 -0
- ai_forge_cli-0.1.2.dist-info/RECORD +21 -0
- ai_forge_cli-0.1.2.dist-info/WHEEL +5 -0
- ai_forge_cli-0.1.2.dist-info/entry_points.txt +2 -0
- ai_forge_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- ai_forge_cli-0.1.2.dist-info/top_level.txt +1 -0
- cli/__init__.py +2 -0
- cli/__main__.py +4 -0
- cli/bundle.py +117 -0
- cli/commands/__init__.py +28 -0
- cli/commands/base.py +26 -0
- cli/commands/context.py +66 -0
- cli/commands/find.py +122 -0
- cli/commands/init.py +447 -0
- cli/commands/inspect.py +111 -0
- cli/commands/list_cmd.py +72 -0
- cli/commands/update.py +78 -0
- cli/common.py +120 -0
- cli/forge.py +65 -0
- cli/index.py +156 -0
- cli/walker.py +799 -0
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)"))
|
cli/commands/inspect.py
ADDED
|
@@ -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")
|
cli/commands/list_cmd.py
ADDED
|
@@ -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
|