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/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
|