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.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. 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