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/update.py ADDED
@@ -0,0 +1,78 @@
1
+ """`forge update` — refresh init-managed project assets in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from cli.commands import init as init_cmd
10
+
11
+ NAME = "update"
12
+ HELP = "Refresh init-managed project assets to the current Forge version."
13
+ DESCRIPTION = (
14
+ "Refreshes the same project-managed assets that `forge init` lays down: "
15
+ "the spec directory structure, schema template symlinks, and project-local "
16
+ "skill symlinks. This command does not overwrite authored spec YAML files. "
17
+ "After refresh, it prints the recommended forge-audit follow-up."
18
+ )
19
+
20
+
21
+ def register(sub: argparse._SubParsersAction) -> None:
22
+ p = sub.add_parser(NAME, help=HELP, description=DESCRIPTION)
23
+ p.add_argument(
24
+ "--spec-subdir", default=".forge",
25
+ help="Relative path from project root for the spec directory. Default: .forge",
26
+ )
27
+ p.add_argument(
28
+ "--skip-skills", action="store_true",
29
+ help="Skip project-local skill symlink refresh.",
30
+ )
31
+ p.set_defaults(handler=run)
32
+
33
+
34
+ def run(args: argparse.Namespace) -> int:
35
+ project_root = Path.cwd()
36
+ spec_dir = project_root / args.spec_subdir
37
+
38
+ if spec_dir.exists() and not spec_dir.is_dir():
39
+ print(f"error: spec path exists and is not a directory: {spec_dir}", file=sys.stderr)
40
+ return 1
41
+ if not spec_dir.exists():
42
+ print(
43
+ f"error: no forge project detected at {spec_dir}/. "
44
+ "Run `forge init` first, or pass --spec-subdir if your project uses a custom spec dir.",
45
+ file=sys.stderr,
46
+ )
47
+ return 1
48
+
49
+ try:
50
+ forge_repo, skills_src = init_cmd._resolve_forge_sources()
51
+ except FileNotFoundError as e:
52
+ lines = str(e).splitlines()
53
+ print(f"error: {lines[0]}", file=sys.stderr)
54
+ for line in lines[1:]:
55
+ print(line, file=sys.stderr)
56
+ return 1
57
+
58
+ print()
59
+ print(init_cmd._color(init_cmd._FIRE_PRIMARY, "▸ ") + init_cmd._bold("Forge update ") + init_cmd._dim(f"in {project_root}"))
60
+ print()
61
+
62
+ ok = init_cmd._color(init_cmd._OK_GREEN, "✓")
63
+
64
+ init_cmd._ensure_spec_structure(project_root, spec_dir, ok=ok)
65
+ init_cmd._install_schema_templates(spec_dir, forge_repo, force=True, ok=ok)
66
+
67
+ if not args.skip_skills:
68
+ init_cmd._install_skill_symlinks(project_root, skills_src, force=True, ok=ok)
69
+
70
+ print()
71
+ print(init_cmd._divider("Next step"))
72
+ print()
73
+ print(init_cmd._dim(" Run a compliance pass against the refreshed project:"))
74
+ print(f" {init_cmd._bold(init_cmd._color(init_cmd._FIRE_PRIMARY, '/forge-audit'))}")
75
+ print(f" {init_cmd._bold('\"Audit the specs before implementation\"')}")
76
+ print()
77
+
78
+ return 0
cli/common.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Shared helpers used by multiple command modules.
3
+
4
+ Keep this module light: argument decorators, index loading, error
5
+ messaging, and text utilities. Anything walker- or bundle-specific
6
+ belongs in those modules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from typing import Any
14
+
15
+ from cli import index as index_mod
16
+
17
+
18
+ # Kind constants used by `list` filter and `inspect` branching.
19
+ BUNDLEABLE_KINDS: tuple[str, ...] = ("atom", "module", "journey", "flow", "artifact")
20
+ ALL_KINDS: tuple[str, ...] = (
21
+ "atom", "module", "journey", "flow", "artifact",
22
+ "policy", "type", "error", "constant", "external_schema", "marker",
23
+ )
24
+
25
+
26
+ # ----------------------------------------------------------------------
27
+ # Argparse decorators — standard flags reused by multiple commands.
28
+ # ----------------------------------------------------------------------
29
+
30
+ def add_spec_dir_arg(parser: argparse.ArgumentParser) -> None:
31
+ """Adds the standard --spec-dir flag. Resolution order is documented in
32
+ resolve_spec_dir: --spec-dir > $FORGE_SPEC_DIR > auto-discover."""
33
+ parser.add_argument(
34
+ "--spec-dir", default=None,
35
+ help="Path to spec directory. Overrides $FORGE_SPEC_DIR and auto-discovery.",
36
+ )
37
+
38
+
39
+ # ----------------------------------------------------------------------
40
+ # Index loading — wraps resolve + load with stderr error messaging.
41
+ # ----------------------------------------------------------------------
42
+
43
+ def load_index(spec_dir_arg: str | None) -> tuple[index_mod.Index | None, int]:
44
+ """Resolve spec dir and load an Index.
45
+
46
+ Returns (index, 0) on success or (None, 1) if resolution/loading fails.
47
+ The error is already printed to stderr by the time 1 is returned, so
48
+ callers can bail with the returned code directly.
49
+ """
50
+ try:
51
+ spec_dir = index_mod.resolve_spec_dir(spec_dir_arg)
52
+ idx = index_mod.load(spec_dir)
53
+ return idx, 0
54
+ except FileNotFoundError as e:
55
+ print(f"error: {e}", file=sys.stderr)
56
+ return None, 1
57
+
58
+
59
+ # ----------------------------------------------------------------------
60
+ # Id suggestion — used by commands that accept an id argument to help
61
+ # users recover from typos.
62
+ # ----------------------------------------------------------------------
63
+
64
+ def suggest_similar(idx: index_mod.Index, target: str, limit: int = 5) -> None:
65
+ """Print up to `limit` ids close to `target`, best matches first.
66
+
67
+ Scoring prefers ids in the same namespace (shared dot-separated
68
+ prefix) over coincidental substring matches. Writes to stderr.
69
+ Silent if there are no reasonable candidates.
70
+ """
71
+ target_low = target.lower()
72
+ target_parts = target_low.split(".")
73
+
74
+ scored: list[tuple[int, str]] = []
75
+ for e in idx.entries.values():
76
+ eid_low = e.id.lower()
77
+ # Score = number of matching leading dot-segments (higher is better).
78
+ eid_parts = eid_low.split(".")
79
+ shared_prefix = 0
80
+ for a, b in zip(target_parts, eid_parts):
81
+ if a == b:
82
+ shared_prefix += 1
83
+ else:
84
+ break
85
+ substring_hit = target_low in eid_low or eid_low in target_low
86
+ if shared_prefix > 0 or substring_hit:
87
+ # Primary key: shared prefix segments; secondary: substring hit.
88
+ score = shared_prefix * 10 + (1 if substring_hit else 0)
89
+ scored.append((score, e.id))
90
+
91
+ if not scored:
92
+ return
93
+ scored.sort(key=lambda x: (-x[0], x[1]))
94
+ print("\nDid you mean:", file=sys.stderr)
95
+ for _, candidate in scored[:limit]:
96
+ print(f" {candidate}", file=sys.stderr)
97
+
98
+
99
+ # ----------------------------------------------------------------------
100
+ # Description utilities — used by list and inspect.
101
+ # ----------------------------------------------------------------------
102
+
103
+ def full_description(data: Any) -> str:
104
+ """Full description text (uncapped) — newlines collapsed to spaces."""
105
+ if not isinstance(data, dict):
106
+ return ""
107
+ desc = data.get("description") or data.get("message") or ""
108
+ if not isinstance(desc, str):
109
+ return ""
110
+ return " ".join(desc.strip().split())
111
+
112
+
113
+ def one_line_description(data: Any, max_chars: int = 80) -> str:
114
+ """Compact description capped for table rendering."""
115
+ text = full_description(data)
116
+ if not text:
117
+ return ""
118
+ if len(text) > max_chars:
119
+ return text[: max_chars - 3] + "..."
120
+ return text
cli/forge.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ `forge` CLI entry point.
3
+
4
+ This module is deliberately thin: it collects registered command
5
+ modules, lets each build its own subparser, and dispatches via
6
+ `args.handler`. Adding a new command requires zero edits here — see
7
+ `cli.commands.__init__` for the registration point.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from importlib import metadata
14
+
15
+ from cli.commands import ALL_COMMANDS
16
+
17
+
18
+ def _version_string() -> str:
19
+ """Return the installed forge-cli package version."""
20
+ for dist_name in ("forge-ai-cli", "forge-cli"):
21
+ try:
22
+ return metadata.version(dist_name)
23
+ except metadata.PackageNotFoundError:
24
+ continue
25
+ return "unknown"
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="forge",
31
+ description=(
32
+ "Context walker for the Forge L0-L5 spec system. "
33
+ "Assembles everything an agent needs to implement an atom, "
34
+ "module, journey, flow, or artifact into a single bundle."
35
+ ),
36
+ epilog=(
37
+ "Examples:\n"
38
+ " forge context atm.pay.charge_card --spec-dir src/example\n"
39
+ " forge list --kind atom\n"
40
+ " forge inspect PAY\n\n"
41
+ "Spec dir resolution: --spec-dir > $FORGE_SPEC_DIR > auto-discover."
42
+ ),
43
+ formatter_class=argparse.RawDescriptionHelpFormatter,
44
+ )
45
+ parser.add_argument(
46
+ "--version",
47
+ action="version",
48
+ version=f"forge {_version_string()}",
49
+ )
50
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
51
+
52
+ for cmd in ALL_COMMANDS:
53
+ cmd.register(sub)
54
+
55
+ return parser
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ parser = _build_parser()
60
+ args = parser.parse_args(argv)
61
+ return args.handler(args)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
cli/index.py ADDED
@@ -0,0 +1,156 @@
1
+ """
2
+ Spec directory loader and flat id index.
3
+
4
+ Loads every YAML under a spec directory (L0 singleton, L1 singleton,
5
+ L2 modules + policies, L3 atoms + artifacts, L4 flows + journeys,
6
+ L5 singleton) and produces a flat dict keyed by id for O(1) lookup.
7
+
8
+ L0 sub-sections (errors, types, constants, external_schemas) are
9
+ exploded into individually-keyed entries so callers can ask for a
10
+ single error code or type id without knowing the registry's shape.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+
24
+ @dataclass
25
+ class Entry:
26
+ id: str
27
+ kind: str # atom|module|journey|flow|artifact|policy|type|error|constant|external_schema|marker
28
+ data: Any
29
+ file: Path | None = None
30
+
31
+
32
+ @dataclass
33
+ class Index:
34
+ spec_dir: Path
35
+ entries: dict[str, Entry] = field(default_factory=dict)
36
+ l0: dict[str, Any] = field(default_factory=dict) # raw L0 registry
37
+ l1: dict[str, Any] = field(default_factory=dict) # raw L1 conventions
38
+ l5: dict[str, Any] = field(default_factory=dict) # raw L5 operations
39
+
40
+ def get(self, entity_id: str) -> Entry | None:
41
+ return self.entries.get(entity_id)
42
+
43
+ def by_kind(self, kind: str) -> list[Entry]:
44
+ return [e for e in self.entries.values() if e.kind == kind]
45
+
46
+ def naming_regex(self, key: str) -> re.Pattern[str] | None:
47
+ pat = self.l0.get("naming_ledger", {}).get(key)
48
+ return re.compile(pat) if pat else None
49
+
50
+
51
+ # ---------- discovery ----------
52
+
53
+ def resolve_spec_dir(explicit: str | None = None) -> Path:
54
+ """Resolve spec dir from explicit arg, env var, or auto-discover."""
55
+ if explicit:
56
+ return Path(explicit).expanduser().resolve()
57
+ env = os.environ.get("FORGE_SPEC_DIR")
58
+ if env:
59
+ return Path(env).expanduser().resolve()
60
+
61
+ cwd = Path.cwd().resolve()
62
+ for candidate in [cwd, *cwd.parents]:
63
+ if (candidate / "L0_registry.yaml").is_file():
64
+ return candidate
65
+ if (candidate / ".forge" / "L0_registry.yaml").is_file():
66
+ return candidate / ".forge"
67
+ # Back-compat for projects initialized before the .forge convention:
68
+ if (candidate / "forge" / "docs" / "L0_registry.yaml").is_file():
69
+ return candidate / "forge" / "docs"
70
+ # Also check for a .forge/ with just the structure (L-layer dirs / templates)
71
+ # even if L0_registry.yaml hasn't been written yet (e.g., fresh init, not yet run discover).
72
+ if (candidate / ".forge").is_dir() and (candidate / ".forge" / "L2_modules").is_dir():
73
+ return candidate / ".forge"
74
+ raise FileNotFoundError(
75
+ "Cannot locate spec dir. Pass --spec-dir, set FORGE_SPEC_DIR, "
76
+ "or run `forge init` to bootstrap a new project."
77
+ )
78
+
79
+
80
+ # ---------- loader ----------
81
+
82
+ def load(spec_dir: Path) -> Index:
83
+ spec_dir = Path(spec_dir).resolve()
84
+ if not spec_dir.is_dir():
85
+ raise FileNotFoundError(f"Spec dir not found: {spec_dir}")
86
+
87
+ idx = Index(spec_dir=spec_dir)
88
+
89
+ _load_singleton(idx, spec_dir / "L0_registry.yaml", slot="l0")
90
+ _load_singleton(idx, spec_dir / "L1_conventions.yaml", slot="l1")
91
+ _load_singleton(idx, spec_dir / "L5_operations.yaml", slot="l5")
92
+
93
+ _explode_l0(idx)
94
+
95
+ _load_dir(idx, spec_dir / "L2_modules", kind="module", top_key="module")
96
+ _load_dir(idx, spec_dir / "L2_policies", kind="policy", top_key="policy")
97
+ _load_dir(idx, spec_dir / "L3_atoms", kind="atom", top_key="atom")
98
+ _load_dir(idx, spec_dir / "L3_artifacts", kind="artifact", top_key="artifact")
99
+ _load_dir(idx, spec_dir / "L4_flows", kind="flow", top_key="orchestration")
100
+ _load_dir(idx, spec_dir / "L4_journeys", kind="journey", top_key="journey")
101
+
102
+ return idx
103
+
104
+
105
+ def _load_singleton(idx: Index, path: Path, slot: str) -> None:
106
+ if not path.is_file():
107
+ return
108
+ with path.open("r", encoding="utf-8") as f:
109
+ data = yaml.safe_load(f) or {}
110
+ setattr(idx, slot, data)
111
+
112
+
113
+ def _load_dir(idx: Index, directory: Path, kind: str, top_key: str) -> None:
114
+ if not directory.is_dir():
115
+ return
116
+ for path in sorted(directory.glob("*.yaml")):
117
+ with path.open("r", encoding="utf-8") as f:
118
+ doc = yaml.safe_load(f) or {}
119
+ body = doc.get(top_key) or {}
120
+ entity_id = body.get("id")
121
+ if not entity_id:
122
+ continue
123
+ idx.entries[entity_id] = Entry(id=entity_id, kind=kind, data=body, file=path)
124
+
125
+
126
+ def _explode_l0(idx: Index) -> None:
127
+ """Index individual errors/types/constants/external_schemas/markers by id."""
128
+ l0 = idx.l0
129
+ for code, body in (l0.get("errors") or {}).items():
130
+ idx.entries[code] = Entry(id=code, kind="error", data=body)
131
+ for tid, body in (l0.get("types") or {}).items():
132
+ idx.entries[tid] = Entry(id=tid, kind="type", data=body)
133
+ for cid, body in (l0.get("constants") or {}).items():
134
+ idx.entries[cid] = Entry(id=cid, kind="constant", data=body)
135
+ for sid, body in (l0.get("external_schemas") or {}).items():
136
+ idx.entries[sid] = Entry(id=sid, kind="external_schema", data=body)
137
+ for marker, desc in (l0.get("side_effect_markers") or {}).items():
138
+ idx.entries[marker] = Entry(id=marker, kind="marker", data={"description": desc})
139
+
140
+
141
+ # ---------- id dispatch ----------
142
+
143
+ _BUNDLEABLE = {"atom", "module", "journey", "flow", "artifact"}
144
+
145
+
146
+ def classify(idx: Index, entity_id: str) -> str:
147
+ """Return the kind for an id, raising if unknown or not bundleable."""
148
+ entry = idx.get(entity_id)
149
+ if entry is None:
150
+ raise KeyError(f"Unknown id: {entity_id}")
151
+ if entry.kind not in _BUNDLEABLE:
152
+ raise ValueError(
153
+ f"{entity_id} is a {entry.kind}; only {sorted(_BUNDLEABLE)} "
154
+ "can be bundled via `forge context`."
155
+ )
156
+ return entry.kind