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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-forge-cli
3
+ Version: 0.1.2
4
+ Summary: Context walker for the Forge L0-L5 spec system
5
+ Requires-Python: >=3.11
6
+ License-File: LICENSE
7
+ Requires-Dist: pyyaml>=6.0
8
+ Dynamic: license-file
@@ -0,0 +1,21 @@
1
+ ai_forge_cli-0.1.2.dist-info/licenses/LICENSE,sha256=6rxKXRsWhsA9SO1zfWXd8jFE_9oKQgq_6DONh9YaANI,1070
2
+ cli/__init__.py,sha256=1nlEjQxN41Hlmc-9GYy1ecLhfJMx5mCRYElym9Zu5RQ,84
3
+ cli/__main__.py,sha256=Gbyw2xwlaGxJHGrtnTvbyeOw6wNhESZe56TxZy0WBNY,84
4
+ cli/bundle.py,sha256=ftLO0DVYrW0h6EcaAtUI85cdkf3pJWzANLGlcTdkkas,4415
5
+ cli/common.py,sha256=amidWJznK9d740tio3E6tpsT2jstmPRj_hASHK1fG_w,4419
6
+ cli/forge.py,sha256=mMShjqxO270u1J-66QTbBwfZ5WeSKKSmbdHdMsnpFV0,1887
7
+ cli/index.py,sha256=PFPDx2WaLpLQVCXhxJTCAysSESaVps7c4i0naBoDmzI,5823
8
+ cli/walker.py,sha256=LVYgUzHVQejB3hyvcmyunAHFVyPYk5CYKwPOFRl-7Wc,28884
9
+ cli/commands/__init__.py,sha256=qzYYjJxLxk_wXOtH_6Dzi-ImA6vOOxJJ3ImL9Ix7T28,754
10
+ cli/commands/base.py,sha256=oi6syCWU8WmCFkZx6W0CthmfRdA2wX6m3MscRbyFaWQ,900
11
+ cli/commands/context.py,sha256=rme5IMQ7EqJDz1HaMbY7iVcVj747SZ9bk1vfQDKThQU,2060
12
+ cli/commands/find.py,sha256=iqsy0FqAVUEz-UcxkMrlhKxHAY1J07Kv8iJLCrRDE5I,3961
13
+ cli/commands/init.py,sha256=5XcI5ucxDEQ9cDfaBH9XQMEtpdN-AsCkdLZzSlgodBA,16068
14
+ cli/commands/inspect.py,sha256=gOba_hDbsK7wmyW5GnwVA7UqE4mFPZj6-Mbw6_zOk7w,4065
15
+ cli/commands/list_cmd.py,sha256=2jRUynPk2doqh5MjWrGpR3AZm8Vx5lhWJ0KVL2rocys,2232
16
+ cli/commands/update.py,sha256=Wckgw-OOxU-g63vlIS-AP-0kKe-vmTjfQE6iWFzPET4,2727
17
+ ai_forge_cli-0.1.2.dist-info/METADATA,sha256=MCzW4kYE2e1CIFnY5QKNW9BbGyA_-3OiiOb6t38rlM8,207
18
+ ai_forge_cli-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ ai_forge_cli-0.1.2.dist-info/entry_points.txt,sha256=rUEWRSNZOi7Hs279NqYig10R5RJIirVP9PUk0LX7DvE,41
20
+ ai_forge_cli-0.1.2.dist-info/top_level.txt,sha256=2ImG917oaVHlm0nP9oJE-Qrgs-fq_fGWgba2H1f8fpE,4
21
+ ai_forge_cli-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ forge = cli.forge:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William James
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1 @@
1
+ cli
cli/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Forge CLI — context walker for the L0-L5 spec system."""
2
+ __version__ = "0.1.0"
cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from cli.forge import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
cli/bundle.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ Bundle formatter — renders a walker output dict as YAML, JSON, or markdown.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from collections import OrderedDict
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+
14
+ # Human-readable headers for each bundle section.
15
+ _SECTION_HEADERS: dict[str, str] = {
16
+ "target": "Target",
17
+ "l0_registry_slice": "L0 — Registry (sliced)",
18
+ "l1_conventions": "L1 — Conventions",
19
+ "l2_module": "L2 — Owner Module",
20
+ "l2_entry_points": "L2 — Invoking Entry Points",
21
+ "policies_applied": "L2 — Policies Applied",
22
+ "policies": "L2 — Policies",
23
+ "shared_module_interfaces": "L2 — Whitelisted Module Interfaces",
24
+ "l3_atom": "L3 — Target Atom",
25
+ "l3_artifact": "L3 — Target Artifact",
26
+ "owned_atoms": "L3 — Owned Atoms",
27
+ "owned_artifacts": "L3 — Owned Artifacts",
28
+ "called_atom_signatures": "L3 — Called Atom Signatures",
29
+ "producer_atom_signature": "L3 — Producer Atom Signature",
30
+ "source_artifacts": "L3 — Upstream Source Artifacts",
31
+ "consumer_signatures": "L3 — Consumer Signatures",
32
+ "training_artifact": "L3 — Training Artifact",
33
+ "handler_atoms": "L3 — Handler Atoms",
34
+ "step_atom_signatures": "L3 — Step Atom Signatures",
35
+ "l4_journey": "L4 — Target Journey",
36
+ "l4_orchestration": "L4 — Target Orchestration",
37
+ "invoked_orchestrations": "L4 — Invoked Orchestrations",
38
+ "l4_callers": "L4 — Callers (atoms consumed here)",
39
+ "l5_operations": "L5 — Operations",
40
+ }
41
+
42
+
43
+ # Ensure PyYAML emits OrderedDicts in insertion order.
44
+ def _represent_ordered_dict(dumper, data):
45
+ return dumper.represent_mapping("tag:yaml.org,2002:map", data.items())
46
+
47
+
48
+ yaml.add_representer(OrderedDict, _represent_ordered_dict)
49
+
50
+
51
+ def render(bundle: OrderedDict[str, Any], fmt: str = "yaml") -> str:
52
+ if fmt == "json":
53
+ return json.dumps(bundle, indent=2, default=_json_default)
54
+ if fmt == "markdown":
55
+ return _render_markdown(bundle)
56
+ return _render_yaml(bundle)
57
+
58
+
59
+ def _render_yaml(bundle: OrderedDict[str, Any]) -> str:
60
+ parts: list[str] = []
61
+ target = bundle.get("target") or {}
62
+ parts.append("# " + "=" * 66)
63
+ parts.append(f"# FORGE CONTEXT BUNDLE")
64
+ parts.append(f"# target: {target.get('id', '<unknown>')}")
65
+ parts.append(f"# kind: {target.get('kind', '<unknown>')}")
66
+ if target.get("atom_kind"):
67
+ parts.append(f"# atom_kind: {target['atom_kind']}")
68
+ parts.append("# " + "=" * 66)
69
+ parts.append("")
70
+
71
+ for key, value in bundle.items():
72
+ if key == "target":
73
+ continue
74
+ if value is None or (isinstance(value, (dict, list)) and not value):
75
+ continue
76
+ header = _SECTION_HEADERS.get(key, key)
77
+ parts.append("# " + "-" * 66)
78
+ parts.append(f"# {header}")
79
+ parts.append("# " + "-" * 66)
80
+ parts.append(yaml.dump({key: value}, sort_keys=False, allow_unicode=True,
81
+ default_flow_style=False, width=100).rstrip())
82
+ parts.append("")
83
+
84
+ return "\n".join(parts) + "\n"
85
+
86
+
87
+ def _render_markdown(bundle: OrderedDict[str, Any]) -> str:
88
+ target = bundle.get("target") or {}
89
+ parts: list[str] = [
90
+ f"# Forge context: `{target.get('id', '<unknown>')}`",
91
+ "",
92
+ f"- **kind**: `{target.get('kind', '<unknown>')}`",
93
+ ]
94
+ if target.get("atom_kind"):
95
+ parts.append(f"- **atom_kind**: `{target['atom_kind']}`")
96
+ parts.append("")
97
+
98
+ for key, value in bundle.items():
99
+ if key == "target":
100
+ continue
101
+ if value is None or (isinstance(value, (dict, list)) and not value):
102
+ continue
103
+ header = _SECTION_HEADERS.get(key, key)
104
+ parts.append(f"## {header}")
105
+ parts.append("")
106
+ parts.append("```yaml")
107
+ parts.append(yaml.dump({key: value}, sort_keys=False, allow_unicode=True,
108
+ default_flow_style=False, width=100).rstrip())
109
+ parts.append("```")
110
+ parts.append("")
111
+ return "\n".join(parts)
112
+
113
+
114
+ def _json_default(o: Any) -> Any:
115
+ if isinstance(o, OrderedDict):
116
+ return dict(o)
117
+ raise TypeError(f"Unserializable: {type(o)}")
@@ -0,0 +1,28 @@
1
+ """
2
+ Command registry.
3
+
4
+ To add a new command:
5
+ 1. Create `cli/commands/<name>.py` exposing NAME, HELP, register, run
6
+ (see `base.py` for the contract, or copy any existing command as a
7
+ starting point).
8
+ 2. Import it below and append to ALL_COMMANDS.
9
+
10
+ That's it — `forge.py` auto-discovers every command in ALL_COMMANDS,
11
+ builds its subparser, and wires dispatch via `args.handler`.
12
+ """
13
+
14
+ from cli.commands import context as _context
15
+ from cli.commands import find as _find
16
+ from cli.commands import init as _init
17
+ from cli.commands import inspect as _inspect
18
+ from cli.commands import list_cmd as _list_cmd
19
+ from cli.commands import update as _update
20
+
21
+ ALL_COMMANDS = [
22
+ _init,
23
+ _update,
24
+ _context,
25
+ _list_cmd,
26
+ _inspect,
27
+ _find,
28
+ ]
cli/commands/base.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ Command module contract.
3
+
4
+ Every module in `cli.commands` must expose:
5
+
6
+ NAME: str
7
+ The subcommand name. Used as `forge <NAME> ...` on the CLI.
8
+
9
+ HELP: str
10
+ One-line help text shown in `forge --help`.
11
+
12
+ def register(sub: argparse._SubParsersAction) -> None:
13
+ Add this command's subparser with all its arguments to the
14
+ provided _SubParsersAction. MUST call
15
+ `parser.set_defaults(handler=run)` on the created subparser so
16
+ dispatch works without any if/elif chain in forge.main.
17
+
18
+ def run(args: argparse.Namespace) -> int:
19
+ Execute the command. Return an exit code:
20
+ 0 = success
21
+ 1 = usage error (unknown id, missing spec dir, etc.)
22
+ 2 = soft warning (e.g., bundle emitted but has unresolved refs)
23
+
24
+ Commands should reuse helpers from `cli.common` rather than reimplementing
25
+ spec-dir loading, id suggestion, or description formatting.
26
+ """
@@ -0,0 +1,66 @@
1
+ """`forge context <id>` — build a full context bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from cli import bundle as bundle_mod
9
+ from cli import common
10
+ from cli import index as index_mod
11
+ from cli import walker
12
+
13
+ NAME = "context"
14
+ HELP = "Build a full implementation-ready context bundle for an id."
15
+ DESCRIPTION = (
16
+ "Walks the spec dependency graph from <id> and emits everything "
17
+ "an agent needs to implement it: the target spec, referenced L0 "
18
+ "entries (sliced, not the whole registry), the owning module, "
19
+ "applicable policies, L1 conventions, L4 callers with derived "
20
+ "implications, and L5 operations."
21
+ )
22
+
23
+
24
+ def register(sub: argparse._SubParsersAction) -> None:
25
+ p = sub.add_parser(NAME, help=HELP, description=DESCRIPTION)
26
+ p.add_argument(
27
+ "id",
28
+ help="Target id. Must be an atom, module, journey, flow, or artifact.",
29
+ )
30
+ common.add_spec_dir_arg(p)
31
+ p.add_argument(
32
+ "--format", choices=["yaml", "json", "markdown"], default="yaml",
33
+ help=(
34
+ "Output format. yaml (default) is most token-efficient. "
35
+ "json is most parseable. markdown wraps each section in a "
36
+ "heading + code block for pasting into a chat."
37
+ ),
38
+ )
39
+ p.set_defaults(handler=run)
40
+
41
+
42
+ def run(args: argparse.Namespace) -> int:
43
+ idx, rc = common.load_index(args.spec_dir)
44
+ if rc != 0:
45
+ return rc
46
+
47
+ try:
48
+ index_mod.classify(idx, args.id)
49
+ except (KeyError, ValueError) as e:
50
+ print(f"error: {e}", file=sys.stderr)
51
+ common.suggest_similar(idx, args.id)
52
+ return 1
53
+
54
+ bundle, unresolved = walker.walk(idx, args.id)
55
+ output = bundle_mod.render(bundle, fmt=args.format)
56
+ sys.stdout.write(output)
57
+
58
+ if unresolved:
59
+ seen: set[str] = set()
60
+ uniq = [u for u in unresolved if not (u in seen or seen.add(u))]
61
+ print(f"\n# Unresolved references ({len(uniq)}):", file=sys.stderr)
62
+ for u in uniq:
63
+ print(f"# - {u}", file=sys.stderr)
64
+ return 2
65
+
66
+ return 0
cli/commands/find.py ADDED
@@ -0,0 +1,122 @@
1
+ """`forge find <query>` — substring search across entity IDs and descriptions.
2
+
3
+ Used as the scanning primitive for reuse-before-create probes in the
4
+ forge-discover and forge-decompose skills. Agents call this before writing
5
+ a new module / atom / type / error to surface potential matches, then
6
+ present them advisorily to the human.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+ from cli import common
15
+
16
+ NAME = "find"
17
+ HELP = "Search for entities whose ID or description matches a query."
18
+ DESCRIPTION = (
19
+ "Case-insensitive substring search across every entity in the spec "
20
+ "directory. Matches on ID (atom/module/type/error name) and on "
21
+ "description. Ranked output: entities matching on both ID and "
22
+ "description rank above single-match hits. Ties break by kind "
23
+ "priority (atoms/modules first) then alphabetical."
24
+ )
25
+
26
+
27
+ def register(sub: argparse._SubParsersAction) -> None:
28
+ p = sub.add_parser(NAME, help=HELP, description=DESCRIPTION)
29
+ p.add_argument(
30
+ "query",
31
+ help="Search string. Case-insensitive substring match.",
32
+ )
33
+ p.add_argument(
34
+ "--kind", choices=common.ALL_KINDS, default=None,
35
+ help="Restrict results to a single kind (atom, module, type, error, etc.).",
36
+ )
37
+ p.add_argument(
38
+ "--limit", type=int, default=10,
39
+ help="Maximum number of matches to show. Default 10. Use 0 for no limit.",
40
+ )
41
+ common.add_spec_dir_arg(p)
42
+ p.set_defaults(handler=run)
43
+
44
+
45
+ def run(args: argparse.Namespace) -> int:
46
+ idx, rc = common.load_index(args.spec_dir)
47
+ if rc != 0:
48
+ return rc
49
+
50
+ if not args.query.strip():
51
+ print("error: query must be non-empty", file=sys.stderr)
52
+ return 1
53
+
54
+ query_lower = args.query.lower()
55
+ kind_priority = {k: i for i, k in enumerate(common.ALL_KINDS)}
56
+
57
+ matches: list[tuple[int, int, str, str, list[str], str]] = []
58
+ # tuple shape: (-score, kind_priority, id, kind, signals, description)
59
+
60
+ for entry in idx.entries.values():
61
+ if args.kind and entry.kind != args.kind:
62
+ continue
63
+
64
+ id_match = query_lower in entry.id.lower()
65
+ description = common.full_description(entry.data)
66
+ desc_match = bool(description) and query_lower in description.lower()
67
+
68
+ if not (id_match or desc_match):
69
+ continue
70
+
71
+ signals: list[str] = []
72
+ if id_match:
73
+ signals.append("id")
74
+ if desc_match:
75
+ signals.append("desc")
76
+ score = len(signals)
77
+
78
+ matches.append((
79
+ -score, # primary: higher score first (neg for asc sort)
80
+ kind_priority.get(entry.kind, 999), # secondary: bundleable kinds first
81
+ entry.id, # tertiary: alphabetical
82
+ entry.kind,
83
+ signals,
84
+ description,
85
+ ))
86
+
87
+ matches.sort()
88
+
89
+ total = len(matches)
90
+ if total == 0:
91
+ print(f"# forge find {args.query!r} — no matches")
92
+ return 0
93
+
94
+ limit = args.limit if args.limit > 0 else total
95
+ shown = min(total, limit)
96
+
97
+ header = f"# forge find {args.query!r} — {total} match{'es' if total != 1 else ''}"
98
+ if shown < total:
99
+ header += f" (showing top {shown})"
100
+ print(header)
101
+ print()
102
+
103
+ # Column widths from the subset we'll actually render.
104
+ subset = matches[:shown]
105
+ id_width = max(len(m[2]) for m in subset)
106
+ kind_width = max(len(m[3]) for m in subset)
107
+
108
+ for _, _, entity_id, kind, signals, description in subset:
109
+ signal_str = f"[{'+'.join(signals)}]"
110
+ desc_preview = _preview(description, 80)
111
+ print(f" {entity_id:<{id_width}} {kind:<{kind_width}} {signal_str:<10} {desc_preview}")
112
+
113
+ return 0
114
+
115
+
116
+ def _preview(text: str, max_chars: int) -> str:
117
+ if not text:
118
+ return ""
119
+ text = " ".join(text.strip().split())
120
+ if len(text) <= max_chars:
121
+ return text
122
+ return text[: max_chars - 3] + "..."