agentbundle 0.2.0__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.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- agentbundle-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""``agentbundle list-packs`` subcommand.
|
|
2
|
+
|
|
3
|
+
Resolves a catalogue URI, enumerates every ``packs/*/pack.toml``, and prints
|
|
4
|
+
a stable table of name, version, description, and dependencies to stdout.
|
|
5
|
+
|
|
6
|
+
Catalogue layout accepted:
|
|
7
|
+
- ``<root>/packs/<name>/pack.toml`` — standard catalogue layout.
|
|
8
|
+
- ``<root>/<name>/pack.toml`` — root *is* the packs directory
|
|
9
|
+
(every subdir with a pack.toml counts).
|
|
10
|
+
|
|
11
|
+
Exit codes:
|
|
12
|
+
0 — success.
|
|
13
|
+
1 — catalogue resolution error (one-line stderr from CatalogueError).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import argparse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(args: "argparse.Namespace") -> int:
|
|
27
|
+
"""Entry point for ``agentbundle list-packs``.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
args.catalogue — catalogue URI (local path or git+https://...).
|
|
31
|
+
|
|
32
|
+
Returns 0 on success, 1 on catalogue resolution failure.
|
|
33
|
+
"""
|
|
34
|
+
from agentbundle.catalogue import CatalogueError, resolve_catalogue
|
|
35
|
+
from agentbundle.config import ConfigError, load_pack_toml
|
|
36
|
+
|
|
37
|
+
catalogue_uri: str = args.catalogue
|
|
38
|
+
|
|
39
|
+
# ── Resolve catalogue URI ──────────────────────────────────────────────────
|
|
40
|
+
try:
|
|
41
|
+
catalogue_dir = resolve_catalogue(catalogue_uri)
|
|
42
|
+
except CatalogueError as exc:
|
|
43
|
+
print(f"list-packs: {exc}", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
# ── Discover packs ────────────────────────────────────────────────────────
|
|
47
|
+
pack_dirs = _discover_pack_dirs(catalogue_dir)
|
|
48
|
+
if not pack_dirs:
|
|
49
|
+
# Nothing to show is not an error; just print the (empty) header.
|
|
50
|
+
_print_table([])
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
# ── Parse each pack.toml ──────────────────────────────────────────────────
|
|
54
|
+
from agentbundle.commands._common import check_spec_version_gate
|
|
55
|
+
|
|
56
|
+
rows: list[dict] = []
|
|
57
|
+
for pack_dir in pack_dirs:
|
|
58
|
+
try:
|
|
59
|
+
toml = load_pack_toml(pack_dir / "pack.toml")
|
|
60
|
+
except ConfigError as exc:
|
|
61
|
+
print(f"list-packs: skipping {pack_dir.name}: {exc}", file=sys.stderr)
|
|
62
|
+
continue
|
|
63
|
+
# Spec-version gate per pack; refuse cataloguing an incompatible
|
|
64
|
+
# pack with a uniform message rather than silently listing it.
|
|
65
|
+
if check_spec_version_gate(toml) is not None:
|
|
66
|
+
return 1
|
|
67
|
+
rows.append(_extract_row(toml))
|
|
68
|
+
|
|
69
|
+
# Sort deterministically by pack name so output is stable across runs.
|
|
70
|
+
rows.sort(key=lambda r: r["name"])
|
|
71
|
+
_print_table(rows)
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Helpers
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _discover_pack_dirs(catalogue_dir: Path) -> list[Path]:
|
|
81
|
+
"""Return pack directories found under *catalogue_dir*.
|
|
82
|
+
|
|
83
|
+
Tries ``<catalogue_dir>/packs/`` first (standard layout). If that
|
|
84
|
+
directory doesn't exist or contains no packs, falls back to treating
|
|
85
|
+
every direct subdirectory of *catalogue_dir* that contains a
|
|
86
|
+
``pack.toml`` as a pack.
|
|
87
|
+
"""
|
|
88
|
+
packs_subdir = catalogue_dir / "packs"
|
|
89
|
+
if packs_subdir.is_dir():
|
|
90
|
+
candidates = [p for p in packs_subdir.iterdir() if p.is_dir()]
|
|
91
|
+
found = [p for p in candidates if (p / "pack.toml").exists()]
|
|
92
|
+
if found:
|
|
93
|
+
return sorted(found, key=lambda p: p.name)
|
|
94
|
+
|
|
95
|
+
# Fallback: catalogue root itself may be a packs directory.
|
|
96
|
+
if catalogue_dir.is_dir():
|
|
97
|
+
fallback = [
|
|
98
|
+
p for p in catalogue_dir.iterdir()
|
|
99
|
+
if p.is_dir() and (p / "pack.toml").exists()
|
|
100
|
+
]
|
|
101
|
+
return sorted(fallback, key=lambda p: p.name)
|
|
102
|
+
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_row(toml: dict) -> dict:
|
|
107
|
+
"""Pull display fields out of a parsed pack.toml dict."""
|
|
108
|
+
pack = toml.get("pack", {})
|
|
109
|
+
name = pack.get("name", "")
|
|
110
|
+
version = pack.get("version", "")
|
|
111
|
+
description = pack.get("description", "")
|
|
112
|
+
|
|
113
|
+
# Dependencies: look for [[pack.dependencies.required]] and
|
|
114
|
+
# [[pack.dependencies.recommended]].
|
|
115
|
+
deps: list[str] = []
|
|
116
|
+
dep_table = pack.get("dependencies", {})
|
|
117
|
+
if isinstance(dep_table, dict):
|
|
118
|
+
for kind in ("required", "recommended"):
|
|
119
|
+
for entry in dep_table.get(kind, []) or []:
|
|
120
|
+
if isinstance(entry, dict):
|
|
121
|
+
dep_name = entry.get("pack", "")
|
|
122
|
+
dep_version = entry.get("version", "")
|
|
123
|
+
dep_str = dep_name
|
|
124
|
+
if dep_version:
|
|
125
|
+
dep_str += f"@{dep_version}"
|
|
126
|
+
if dep_str:
|
|
127
|
+
deps.append(dep_str)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"name": name,
|
|
131
|
+
"version": version,
|
|
132
|
+
"description": description,
|
|
133
|
+
"dependencies": deps,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _print_table(rows: list[dict]) -> None:
|
|
138
|
+
"""Print a fixed-column table to stdout.
|
|
139
|
+
|
|
140
|
+
Columns: name, version, description, dependencies.
|
|
141
|
+
Output is deterministic: rows are already sorted by caller; column
|
|
142
|
+
widths are derived from content so the table is alignment-stable.
|
|
143
|
+
"""
|
|
144
|
+
headers = ["NAME", "VERSION", "DESCRIPTION", "DEPENDENCIES"]
|
|
145
|
+
|
|
146
|
+
# Convert deps list to a display string.
|
|
147
|
+
display_rows = [
|
|
148
|
+
{
|
|
149
|
+
"name": r["name"],
|
|
150
|
+
"version": r["version"],
|
|
151
|
+
"description": r["description"],
|
|
152
|
+
"dependencies": ", ".join(r["dependencies"]) if r["dependencies"] else "-",
|
|
153
|
+
}
|
|
154
|
+
for r in rows
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# Compute column widths.
|
|
158
|
+
col_keys = ["name", "version", "description", "dependencies"]
|
|
159
|
+
widths = [len(h) for h in headers]
|
|
160
|
+
for row in display_rows:
|
|
161
|
+
for i, key in enumerate(col_keys):
|
|
162
|
+
widths[i] = max(widths[i], len(row[key]))
|
|
163
|
+
|
|
164
|
+
# Format string: left-justify each column.
|
|
165
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
166
|
+
|
|
167
|
+
print(fmt.format(*headers))
|
|
168
|
+
print(fmt.format(*("-" * w for w in widths)))
|
|
169
|
+
for row in display_rows:
|
|
170
|
+
print(fmt.format(*(row[k] for k in col_keys)))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""`agentbundle list-targets` subcommand.
|
|
2
|
+
|
|
3
|
+
Prints one adapter name per line, in stable sort order, then exits 0.
|
|
4
|
+
|
|
5
|
+
The list is derived from `agentbundle.render.list_adapters()`, which queries
|
|
6
|
+
the runtime registry at `agentbundle.build.adapters.registry` — not a
|
|
7
|
+
hardcoded constant. Adding an adapter to the registry makes it appear here
|
|
8
|
+
automatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from agentbundle.render import list_adapters
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(args: argparse.Namespace) -> int: # noqa: ARG001
|
|
20
|
+
"""Print one adapter name per line; exit 0."""
|
|
21
|
+
for name in list_adapters():
|
|
22
|
+
print(name)
|
|
23
|
+
return 0
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""``agentbundle reconcile`` — read-only orphan reporter.
|
|
2
|
+
|
|
3
|
+
Per RFC-0005 § Follow-on artifacts and AC26: walks the Claude Code
|
|
4
|
+
settings file (``~/.claude/settings.json``) and every Kiro agent JSON
|
|
5
|
+
named in user-scope state, comparing the on-disk ``hooks.<event>``
|
|
6
|
+
arrays against the state file's ``hook-wiring-owned`` rows to surface
|
|
7
|
+
two classes of orphan:
|
|
8
|
+
|
|
9
|
+
- **orphan-in-file** — the target file claims an id-tagged entry the
|
|
10
|
+
state file doesn't know about. Surfaces when a hand-edit on the
|
|
11
|
+
settings file (or on a Kiro agent JSON) adds an entry with an id
|
|
12
|
+
that no installed pack owns.
|
|
13
|
+
- **orphan-in-state** — the state file claims ownership of an entry
|
|
14
|
+
the target file doesn't have. Surfaces when the adopter
|
|
15
|
+
hand-deletes an entry without uninstalling the pack, or when
|
|
16
|
+
multi-machine sync moves a state row without its corresponding
|
|
17
|
+
target-file content.
|
|
18
|
+
|
|
19
|
+
Output is grouped by adapter (one heading per adapter that has any
|
|
20
|
+
orphans, plus an "all clean" line when there are none). The
|
|
21
|
+
subcommand is **read-only**: it does not register an ``--apply`` flag.
|
|
22
|
+
A write-mode reconciler would re-create the merge-discipline problems
|
|
23
|
+
RFC-0005 is designed to avoid; the adopter takes manual action from
|
|
24
|
+
this report.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
import argparse
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run(args: "argparse.Namespace") -> int:
|
|
39
|
+
"""Entry point for ``agentbundle reconcile``.
|
|
40
|
+
|
|
41
|
+
The only supported scope today is ``user`` (RFC-0005's report
|
|
42
|
+
surface). A future RFC may extend to repo scope; ``--scope``
|
|
43
|
+
is accepted with that single value for now.
|
|
44
|
+
"""
|
|
45
|
+
from agentbundle import scope as scope_mod
|
|
46
|
+
from agentbundle.config import ConfigError, load_state
|
|
47
|
+
|
|
48
|
+
cli_scope: str | None = getattr(args, "scope", None)
|
|
49
|
+
if cli_scope != "user":
|
|
50
|
+
print(
|
|
51
|
+
"reconcile: only --scope user is supported today",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
user_root = scope_mod.resolve_user_root()
|
|
58
|
+
except scope_mod.UserScopeUnresolvable:
|
|
59
|
+
print(
|
|
60
|
+
"reconcile: cannot resolve user scope: $HOME unset or invalid",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
state_path = user_root / ".agentbundle" / "state.toml"
|
|
66
|
+
if not state_path.exists():
|
|
67
|
+
# An absent state file means no installs at user scope; no
|
|
68
|
+
# orphans to report against. Cleanly exit 0 with the all-clean
|
|
69
|
+
# line so wrapper scripts can short-circuit.
|
|
70
|
+
print("reconcile: all clean (no user-scope state)")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
state = load_state(state_path)
|
|
75
|
+
except ConfigError as exc:
|
|
76
|
+
print(f"reconcile: {exc}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
orphans_by_adapter = _collect_orphans(state, user_root)
|
|
80
|
+
return _print_report(orphans_by_adapter)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _collect_orphans(state, user_root: Path) -> dict[str, list[str]]:
|
|
84
|
+
"""Walk the target files referenced by state and compute orphans.
|
|
85
|
+
|
|
86
|
+
Returns a dict of adapter name → list of orphan lines (each line
|
|
87
|
+
a human-readable description suitable for direct print).
|
|
88
|
+
"""
|
|
89
|
+
# Group state's hook-wiring-owned rows by (adapter, target-file)
|
|
90
|
+
# so we read each target file exactly once. The default target for
|
|
91
|
+
# claude-code rows is ``~/.claude/settings.json``; kiro rows always
|
|
92
|
+
# carry an explicit ``target-file``.
|
|
93
|
+
grouped: dict[tuple[str, str], set[tuple[str, str]]] = {}
|
|
94
|
+
for pack_name, pack_state in state.packs.items():
|
|
95
|
+
if pack_state.scope != "user":
|
|
96
|
+
continue
|
|
97
|
+
adapter = pack_state.adapter or "claude-code"
|
|
98
|
+
for row in pack_state.hook_wiring_owned:
|
|
99
|
+
event = row.get("event")
|
|
100
|
+
entry_id = row.get("id")
|
|
101
|
+
if not (isinstance(event, str) and isinstance(entry_id, str)):
|
|
102
|
+
continue
|
|
103
|
+
target_rel = row.get("target-file")
|
|
104
|
+
if not target_rel:
|
|
105
|
+
target_rel = (
|
|
106
|
+
".claude/settings.json" if adapter == "claude-code" else ""
|
|
107
|
+
)
|
|
108
|
+
if not target_rel:
|
|
109
|
+
continue
|
|
110
|
+
grouped.setdefault((adapter, target_rel), set()).add((event, entry_id))
|
|
111
|
+
|
|
112
|
+
orphans_by_adapter: dict[str, list[str]] = {}
|
|
113
|
+
for (adapter, target_rel), owned_pairs in grouped.items():
|
|
114
|
+
target_path = user_root / target_rel.lstrip("/")
|
|
115
|
+
file_pairs: set[tuple[str, str]] = set()
|
|
116
|
+
if target_path.exists():
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(target_path.read_text(encoding="utf-8"))
|
|
119
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
120
|
+
orphans_by_adapter.setdefault(adapter, []).append(
|
|
121
|
+
f" unparseable target {target_rel}: {exc}"
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
if isinstance(data, dict):
|
|
125
|
+
hooks = data.get("hooks", {})
|
|
126
|
+
if isinstance(hooks, dict):
|
|
127
|
+
for event, entries in hooks.items():
|
|
128
|
+
if not isinstance(entries, list):
|
|
129
|
+
continue
|
|
130
|
+
for entry in entries:
|
|
131
|
+
if isinstance(entry, dict) and isinstance(entry.get("id"), str):
|
|
132
|
+
file_pairs.add((event, entry["id"]))
|
|
133
|
+
|
|
134
|
+
# orphan-in-file: present in file, absent from state.
|
|
135
|
+
for event, entry_id in sorted(file_pairs - owned_pairs):
|
|
136
|
+
orphans_by_adapter.setdefault(adapter, []).append(
|
|
137
|
+
f" orphan-in-file: {target_rel} carries (event={event}, id={entry_id}) "
|
|
138
|
+
f"but no installed pack owns it"
|
|
139
|
+
)
|
|
140
|
+
# orphan-in-state: present in state, absent from file.
|
|
141
|
+
for event, entry_id in sorted(owned_pairs - file_pairs):
|
|
142
|
+
orphans_by_adapter.setdefault(adapter, []).append(
|
|
143
|
+
f" orphan-in-state: state records ownership of (event={event}, "
|
|
144
|
+
f"id={entry_id}) in {target_rel} but the entry is missing on disk"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return orphans_by_adapter
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _print_report(orphans_by_adapter: dict[str, list[str]]) -> int:
|
|
151
|
+
if not orphans_by_adapter:
|
|
152
|
+
print("reconcile: all clean")
|
|
153
|
+
return 0
|
|
154
|
+
for adapter in sorted(orphans_by_adapter.keys()):
|
|
155
|
+
lines = orphans_by_adapter[adapter]
|
|
156
|
+
if not lines:
|
|
157
|
+
continue
|
|
158
|
+
print(f"reconcile: {adapter}")
|
|
159
|
+
for line in lines:
|
|
160
|
+
print(line)
|
|
161
|
+
return 0
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""`agentbundle render` — project a pack to --output via the F-build pipeline.
|
|
2
|
+
|
|
3
|
+
Option (b) from the task spec: call `render.render_pack()` in-memory,
|
|
4
|
+
then walk the dict-of-bytes and write each entry via `safety.write_jailed`.
|
|
5
|
+
This is the more defensive shape: the path-jail check fires on every write,
|
|
6
|
+
not just as a pre-flight.
|
|
7
|
+
|
|
8
|
+
The three RFC-0001 default recipes are run when --target is absent. When
|
|
9
|
+
--target is given, only recipes whose adapter matches the target are run
|
|
10
|
+
(the aggregate `marketplace` recipe has no adapter and is included unless
|
|
11
|
+
filtered by a named target that doesn't match it).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from agentbundle import render as _render
|
|
20
|
+
from agentbundle.build.main import DEFAULT_RECIPES, load_recipe
|
|
21
|
+
from agentbundle.commands._common import check_spec_version_gate
|
|
22
|
+
from agentbundle.config import ConfigError, load_pack_toml
|
|
23
|
+
from agentbundle.safety import PathJailError, write_jailed
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(args) -> int:
|
|
27
|
+
"""Entry point for `agentbundle render <pack_path> --output <dir> [--target <t>]`."""
|
|
28
|
+
pack_path = Path(args.pack_path).resolve()
|
|
29
|
+
output_dir = Path(args.output).resolve()
|
|
30
|
+
|
|
31
|
+
# Validate pack_path
|
|
32
|
+
if not (pack_path / "pack.toml").exists():
|
|
33
|
+
print(
|
|
34
|
+
f"render: no pack.toml found at {pack_path}",
|
|
35
|
+
file=sys.stderr,
|
|
36
|
+
)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
# Spec-version gate (AC #14 — uniform refusal across subcommands).
|
|
40
|
+
try:
|
|
41
|
+
gate = check_spec_version_gate(load_pack_toml(pack_path / "pack.toml"))
|
|
42
|
+
except ConfigError as exc:
|
|
43
|
+
print(f"render: {exc}", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
if gate is not None:
|
|
46
|
+
return gate
|
|
47
|
+
|
|
48
|
+
# Determine recipe set
|
|
49
|
+
recipes = _select_recipes(getattr(args, "target", None))
|
|
50
|
+
if recipes is None:
|
|
51
|
+
# Unknown target
|
|
52
|
+
from agentbundle.build.adapters import ADAPTERS
|
|
53
|
+
|
|
54
|
+
known = sorted(ADAPTERS.keys())
|
|
55
|
+
target = getattr(args, "target", None)
|
|
56
|
+
print(
|
|
57
|
+
f"render: unknown target {target!r}; known targets: {', '.join(known)}",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
# Render in-memory
|
|
63
|
+
try:
|
|
64
|
+
file_tree = _render.render_pack(pack_path, recipes=recipes)
|
|
65
|
+
except FileNotFoundError as exc:
|
|
66
|
+
print(f"render: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
print(f"render: schema error: {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
# Tier-2 awareness is opt-in via --self-host. Without the flag,
|
|
73
|
+
# `render` writes the projection wholesale (matching `make build`'s
|
|
74
|
+
# dist/ semantic) even if a state file happens to sit at --output.
|
|
75
|
+
# With the flag, --output is treated as an adopter root: collisions
|
|
76
|
+
# with adopter-edited content produce .upstream.<ext> companions.
|
|
77
|
+
self_host_mode = bool(getattr(args, "self_host", False))
|
|
78
|
+
state_path = output_dir / ".agentbundle-state.toml"
|
|
79
|
+
state = None
|
|
80
|
+
if self_host_mode:
|
|
81
|
+
if not state_path.exists():
|
|
82
|
+
print(
|
|
83
|
+
f"render: --self-host requires .agentbundle-state.toml at {output_dir} "
|
|
84
|
+
f"(install or init-state first)",
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
87
|
+
return 1
|
|
88
|
+
from agentbundle.config import load_state
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
state = load_state(state_path)
|
|
92
|
+
except ConfigError as exc:
|
|
93
|
+
print(f"render: {exc}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
# Write each file via write_jailed (path-jail is non-optional)
|
|
97
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
for relpath, content in sorted(file_tree.items()):
|
|
99
|
+
target = output_dir / relpath
|
|
100
|
+
if self_host_mode and target.exists():
|
|
101
|
+
from agentbundle import safety as _safety
|
|
102
|
+
|
|
103
|
+
if _safety.sha256_file(target) != _safety.sha256_bytes(content):
|
|
104
|
+
# Adopter-edited Tier-2; drop companion, leave original.
|
|
105
|
+
try:
|
|
106
|
+
_safety.write_companion(output_dir, relpath, content)
|
|
107
|
+
except PathJailError as exc:
|
|
108
|
+
print(f"render: {exc}", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
print(f"{relpath} (companion)")
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
write_jailed(output_dir, relpath, content)
|
|
114
|
+
except PathJailError as exc:
|
|
115
|
+
print(f"render: {exc}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
print(relpath)
|
|
118
|
+
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _canonicalise_target(name: str) -> str | None:
|
|
123
|
+
"""Return the hyphenated canonical adapter name, or None if unknown.
|
|
124
|
+
|
|
125
|
+
Accepts both the contract-form `claude-code` and the Python-module form
|
|
126
|
+
`claude_code` so adopters don't have to remember which the CLI flag
|
|
127
|
+
expects. Single source of truth for the hyphen/underscore duality;
|
|
128
|
+
`list-targets` calls this too.
|
|
129
|
+
|
|
130
|
+
The literal `apm` is a special case — an aggregate recipe in F-build,
|
|
131
|
+
not a per-adapter projection — and is accepted as a recipe filter even
|
|
132
|
+
though it isn't in the module-keyed `registry`.
|
|
133
|
+
"""
|
|
134
|
+
from agentbundle.build.adapters import ADAPTERS
|
|
135
|
+
|
|
136
|
+
known_hyphenated = set(ADAPTERS.keys()) | {"apm"}
|
|
137
|
+
if name in known_hyphenated:
|
|
138
|
+
return name
|
|
139
|
+
if name.replace("_", "-") in known_hyphenated:
|
|
140
|
+
return name.replace("_", "-")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _select_recipes(target: str | None) -> list[str] | None:
|
|
145
|
+
"""Return the recipe list for the given target (or DEFAULT_RECIPES if None).
|
|
146
|
+
|
|
147
|
+
Returns None if the target is specified but unknown.
|
|
148
|
+
"""
|
|
149
|
+
if target is None:
|
|
150
|
+
return list(DEFAULT_RECIPES)
|
|
151
|
+
|
|
152
|
+
canonical = _canonicalise_target(target)
|
|
153
|
+
if canonical is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Filter DEFAULT_RECIPES to those whose adapter matches, plus adapter-less recipes.
|
|
157
|
+
selected: list[str] = []
|
|
158
|
+
for recipe_name in DEFAULT_RECIPES:
|
|
159
|
+
try:
|
|
160
|
+
recipe = load_recipe(recipe_name)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
continue
|
|
163
|
+
if recipe.adapter is None or recipe.adapter == canonical:
|
|
164
|
+
selected.append(recipe_name)
|
|
165
|
+
return selected
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""T4: `scaffold` subcommand — drop a pack's seeds/ into --output.
|
|
2
|
+
|
|
3
|
+
Iterates the pack's `seeds/` subdirectory recursively. For each file:
|
|
4
|
+
|
|
5
|
+
- Absent on disk (Tier-1 fast-path): write the seed.
|
|
6
|
+
- Present, content matches: no-op (already in sync).
|
|
7
|
+
- Present, content differs (Tier-2): write a `.upstream.<ext>` companion
|
|
8
|
+
next to the original; leave original
|
|
9
|
+
untouched.
|
|
10
|
+
|
|
11
|
+
Every write routes through `safety.write_jailed` (path-jail is non-optional).
|
|
12
|
+
`scaffold` does NOT write `.agentbundle-state.toml` — that is `install`'s job.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from agentbundle import safety
|
|
22
|
+
from agentbundle.commands._common import check_spec_version_gate, deliver_seeds
|
|
23
|
+
from agentbundle.config import ConfigError, load_pack_toml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(args: argparse.Namespace) -> int:
|
|
27
|
+
"""Entry point for `agentbundle scaffold`.
|
|
28
|
+
|
|
29
|
+
Returns 0 on success, 1 on error.
|
|
30
|
+
"""
|
|
31
|
+
packs_dir = Path(args.packs_dir)
|
|
32
|
+
pack_name = args.pack
|
|
33
|
+
output = Path(args.output)
|
|
34
|
+
|
|
35
|
+
pack_dir = packs_dir / pack_name
|
|
36
|
+
seeds_dir = pack_dir / "seeds"
|
|
37
|
+
|
|
38
|
+
pack_toml_path = pack_dir / "pack.toml"
|
|
39
|
+
if pack_toml_path.exists():
|
|
40
|
+
try:
|
|
41
|
+
gate = check_spec_version_gate(load_pack_toml(pack_toml_path))
|
|
42
|
+
except ConfigError as exc:
|
|
43
|
+
print(f"scaffold: {exc}", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
if gate is not None:
|
|
46
|
+
return gate
|
|
47
|
+
|
|
48
|
+
if not seeds_dir.is_dir():
|
|
49
|
+
print(f"no seeds/ in pack {pack_name}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
# Seed delivery (Tier-1/2/3, composition-fragment handling) is shared with
|
|
53
|
+
# `install`; see ``commands._common.deliver_seeds``. `scaffold` does NOT
|
|
54
|
+
# write `.agentbundle-state.toml` — that is `install`'s job.
|
|
55
|
+
try:
|
|
56
|
+
deliveries = deliver_seeds(seeds_dir, output)
|
|
57
|
+
except safety.PathJailError as exc:
|
|
58
|
+
print(f"scaffold: {exc}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
for rec in deliveries:
|
|
62
|
+
if rec.action == "wrote":
|
|
63
|
+
print(f"{rec.relpath}: wrote (new)")
|
|
64
|
+
elif rec.action == "skipped":
|
|
65
|
+
print(f"{rec.relpath}: up-to-date (skipped)")
|
|
66
|
+
else: # companion
|
|
67
|
+
print(f"{rec.relpath}: kept original, wrote companion {rec.companion_relpath}")
|
|
68
|
+
|
|
69
|
+
return 0
|