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
|
@@ -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,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
cli/__main__.py
ADDED
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)}")
|
cli/commands/__init__.py
ADDED
|
@@ -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
|
+
"""
|
cli/commands/context.py
ADDED
|
@@ -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] + "..."
|