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
agentbundle/cli.py ADDED
@@ -0,0 +1,486 @@
1
+ """`agentbundle` CLI dispatcher — argparse over the eleven F-cli subcommands.
2
+
3
+ Subcommand order on the parser matches the canonical install-workflow order
4
+ from the spec (discovery-first): `list-packs`, `list-targets`, `scaffold`,
5
+ `install`, `validate`, `render`, `adapt`, `diff`, `upgrade`, `uninstall`,
6
+ `init-state`.
7
+
8
+ Each subcommand's `run(args) -> int` lives under `agentbundle.commands.*`;
9
+ this module wires `argparse` and prints `--version`. No business logic here.
10
+
11
+ RFC-0004 surface additions:
12
+ - `--scope {repo,user}` on install, uninstall, upgrade, diff, init-state,
13
+ list-targets (the six subcommands enumerated in spec § *Install-scope
14
+ dimension*).
15
+ - `--force` on install only (cross-scope conflict bypass; see
16
+ spec § *Dual-scope install conflict*).
17
+ - Forbidden flags on the five excluded subcommands surface with the
18
+ spec's exact stderr contract: `unknown flag for <verb>: <flag>`.
19
+ `argparse`'s default text (`error: unrecognized arguments:`) omits
20
+ the verb and shapes the prefix differently, so a custom subclass
21
+ over `error()` rewrites the message before exiting.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import re
28
+ import sys
29
+ from typing import Sequence
30
+
31
+ from agentbundle.version import CLI_VERSION, SPEC_VERSION
32
+
33
+
34
+ # Path-bearing argparse-attribute names. The set is curated rather than
35
+ # "every string attribute" so a future flag carrying a content string
36
+ # with a literal backslash (a regex fragment, a message body) is not
37
+ # silently mangled. Update this list — and the corresponding test in
38
+ # `tests/unit/test_cli_path_normalisation.py` — when adding a new
39
+ # path-bearing flag.
40
+ _PATH_BEARING_ATTRS = frozenset(
41
+ {
42
+ "output",
43
+ "output_dir",
44
+ "root",
45
+ "pack_path",
46
+ "packs_dir",
47
+ "catalogue",
48
+ "values_from",
49
+ # `path` is the validate-subcommand positional in the sibling
50
+ # `agentbundle.build` parser; it points at adapter.toml / a
51
+ # contract file. Both entry points run the same normaliser
52
+ # over the same allow-list so a backslash works equally on
53
+ # `agentbundle render packs\core` and `python -m
54
+ # agentbundle.build validate docs\contracts\adapter.toml`.
55
+ "path",
56
+ }
57
+ )
58
+
59
+
60
+ # Flags the spec's stderr contract names by hand. `error()` re-emits
61
+ # any "unrecognized arguments: --scope[=value]" or "--force" mention
62
+ # from argparse with the documented `unknown flag for <verb>: <flag>`
63
+ # shape. Other unrecognised flags keep argparse's default text so we
64
+ # don't accidentally swallow typos.
65
+ _REWRITE_FLAGS = ("--scope", "--force", "--force-merge")
66
+
67
+
68
+ class _VerbAwareParser(argparse.ArgumentParser):
69
+ """An ArgumentParser that knows its verb and rewrites the
70
+ "unrecognized arguments" error for `--scope` / `--force` to match
71
+ the spec's exact stderr contract.
72
+
73
+ `prog` carries the verb name on subparsers (parent argparse sets
74
+ `prog = "<parent-prog> <subcommand>"`), so the verb is the last
75
+ whitespace-delimited token. The rewrite captures the bare flag
76
+ (stripping any `=value` suffix that argparse merged into one token
77
+ when the user wrote `--scope=user`) and emits the documented
78
+ `unknown flag for <verb>: <flag>` line.
79
+
80
+ On the *subparser*, `error()` is called from
81
+ `_VerbAwareSubParsersAction.__call__` when extras with spec flags
82
+ are detected — the override here picks up the verb from
83
+ `self.prog`. On the main parser, `error()` is reached only when
84
+ none of the extras matched a spec flag (subparser-level interception
85
+ already covered those), so the override falls through to argparse's
86
+ default behaviour.
87
+ """
88
+
89
+ def error(self, message: str) -> None: # type: ignore[override]
90
+ match = re.match(r"^unrecognized arguments: (\S+)", message)
91
+ if match is not None:
92
+ token = match.group(1)
93
+ bare = token.split("=", 1)[0]
94
+ if bare in _REWRITE_FLAGS and " " in self.prog:
95
+ # On subparsers, prog is "<parent> <verb>" — extract verb.
96
+ verb = self.prog.rsplit(" ", 1)[-1]
97
+ sys.stderr.write(f"unknown flag for {verb}: {bare}\n")
98
+ raise SystemExit(2)
99
+ super().error(message)
100
+
101
+
102
+ class _VerbAwareSubParsersAction(argparse._SubParsersAction):
103
+ """Hijack subparser dispatch to surface spec-flag refusals at the
104
+ *subparser* level so the verb in the stderr message is correct.
105
+
106
+ Default `_SubParsersAction.__call__` parses the subcommand's args
107
+ with `parse_known_args` and stores extras on the main namespace;
108
+ the main parser then surfaces "unrecognized arguments" later, with
109
+ its own `prog` (no verb). By calling `subparser.error()` ourselves
110
+ when extras include `--scope` or `--force`, the error path
111
+ inherits the subparser's prog (`agentbundle list-packs`), and
112
+ `_VerbAwareParser.error` rewrites it to the documented contract.
113
+
114
+ Non-spec-flag extras propagate normally — we only intercept the
115
+ two flags the spec names byte-for-byte.
116
+ """
117
+
118
+ def __call__(self, parser, namespace, values, option_string=None): # type: ignore[override]
119
+ parser_name = values[0]
120
+ arg_strings = values[1:]
121
+ if parser_name not in self._name_parser_map:
122
+ return super().__call__(parser, namespace, values, option_string)
123
+ subparser = self._name_parser_map[parser_name]
124
+ subnamespace, extras = subparser.parse_known_args(arg_strings, None)
125
+ # Copy parsed attrs into the main namespace as argparse would.
126
+ for key, value in vars(subnamespace).items():
127
+ setattr(namespace, key, value)
128
+ # Intercept spec-flag extras at the subparser level.
129
+ for token in extras:
130
+ bare = token.split("=", 1)[0]
131
+ if bare in _REWRITE_FLAGS:
132
+ # Calls _VerbAwareParser.error on the subparser; that
133
+ # path rewrites to the spec's stderr contract.
134
+ subparser.error(f"unrecognized arguments: {bare}")
135
+ return # unreachable — error() raises SystemExit
136
+ # No spec-flag extras — re-propagate everything for argparse's
137
+ # default unrecognised-args path on the main parser.
138
+ if extras:
139
+ vars(namespace).setdefault("_unrecognized_args", [])
140
+ getattr(namespace, "_unrecognized_args").extend(extras)
141
+
142
+
143
+ def _version_string() -> str:
144
+ return f"agentbundle {CLI_VERSION} (spec {SPEC_VERSION})"
145
+
146
+
147
+ def _shipped_adapters_choices() -> tuple[str, ...]:
148
+ """Derive argparse `--adapter` `choices=` from the live contract.
149
+
150
+ Every shipped adapter (not just user-scope-capable ones), per
151
+ RFC-0011 AC11: the handler issues the pinned refuse-and-explain
152
+ when an adopter passes a shipped-but-not-user-scope-capable adapter
153
+ (e.g. `--adapter copilot`), and argparse must accept the value
154
+ first for the handler to be reached.
155
+ """
156
+ from agentbundle.scope import shipped_adapters_from_contract
157
+
158
+ return shipped_adapters_from_contract()
159
+
160
+
161
+ def _build_parser() -> argparse.ArgumentParser:
162
+ parser = _VerbAwareParser(
163
+ prog="agentbundle",
164
+ description=(
165
+ "Reference CLI for the agent-ready-repo adapter contract. "
166
+ "Library-first counterpart to the `adapt-to-project` LLM skill."
167
+ ),
168
+ )
169
+ # Replace argparse's default _SubParsersAction with the verb-aware
170
+ # subclass that surfaces --scope / --force refusals on the
171
+ # subparser (correct verb in the stderr message).
172
+ parser.register("action", "parsers", _VerbAwareSubParsersAction)
173
+ parser.add_argument(
174
+ "--version",
175
+ action="version",
176
+ version=_version_string(),
177
+ )
178
+
179
+ # Use _VerbAwareParser for every subparser so the forbidden-flag
180
+ # error message names the verb correctly.
181
+ subparsers = parser.add_subparsers(
182
+ dest="command",
183
+ metavar="<command>",
184
+ parser_class=_VerbAwareParser,
185
+ )
186
+
187
+ # --- list-packs --- (no --scope; catalogue query, scope unbound)
188
+ sp = subparsers.add_parser(
189
+ "list-packs",
190
+ help="List packs available in a catalogue URI (local path or git+https).",
191
+ )
192
+ sp.add_argument("catalogue", help="Catalogue URI (local path or git+https://...).")
193
+ sp.set_defaults(func=_lazy("list_packs"))
194
+
195
+ # --- list-targets --- (--scope as read-only filter)
196
+ sp = subparsers.add_parser(
197
+ "list-targets",
198
+ help="List adapter targets the CLI supports (claude-code, kiro-ide, kiro-cli, kiro (deprecated → kiro-ide), copilot, codex).",
199
+ )
200
+ sp.add_argument("--scope", choices=("repo", "user"))
201
+ sp.set_defaults(func=_lazy("list_targets"))
202
+
203
+ # --- scaffold --- (no --scope; always repo-targeted)
204
+ sp = subparsers.add_parser(
205
+ "scaffold",
206
+ help="Drop a pack's seeds/ into --output, honouring Tier-1/2/3 file-safety.",
207
+ )
208
+ sp.add_argument("--pack", default="core")
209
+ sp.add_argument("--packs-dir", default="packs")
210
+ sp.add_argument("--output", required=True)
211
+ sp.set_defaults(func=_lazy("scaffold"))
212
+
213
+ # --- install --- (--scope override + --force cross-scope bypass)
214
+ sp = subparsers.add_parser(
215
+ "install",
216
+ help="Install a pack from a catalogue URI into the adopter repo.",
217
+ )
218
+ sp.add_argument("--pack", required=True)
219
+ sp.add_argument("catalogue", help="Catalogue URI (local path or git+https://...).")
220
+ sp.add_argument("--output", default=".")
221
+ sp.add_argument("--scope", choices=("repo", "user"))
222
+ sp.add_argument(
223
+ "--force",
224
+ action="store_true",
225
+ help=(
226
+ "RFC-0004: bypass the cross-scope-conflict refusal — install at "
227
+ "the requested scope even when the pack is already installed at "
228
+ "the other scope. Also REMOVES on-disk files at the pack's "
229
+ "projection paths that the current version does not ship "
230
+ "(unrecognized leftovers from an older or interrupted install) "
231
+ "before reinstalling. Does *not* override the in-place re-install "
232
+ "refusal; use `upgrade` for that."
233
+ ),
234
+ )
235
+ sp.add_argument(
236
+ "--force-merge",
237
+ action="store_true",
238
+ help=(
239
+ "RFC-0005: adopt an adopter-hand-authored entry under "
240
+ "`~/.claude/settings.json` whose `command` collides with the "
241
+ "pack's hook. Bound to `install --scope user` against a "
242
+ "Claude-Code-targeted pack only; original command preserved "
243
+ "in the state-file snapshot."
244
+ ),
245
+ )
246
+ # RFC-0011 / pack-allowed-adapters AC11: optional `--adapter`
247
+ # override at install time. choices=every-shipped-adapter (not
248
+ # just user-scope-capable) so the handler-level user-scope check
249
+ # can issue the pinned refuse-and-explain for copilot rather than
250
+ # argparse's stock "invalid choice" error.
251
+ _shipped_for_cli = _shipped_adapters_choices()
252
+ sp.add_argument(
253
+ "--adapter",
254
+ choices=_shipped_for_cli,
255
+ help=(
256
+ "Override the auto-detected adapter. Admitted at both "
257
+ "install scopes (RFC-0012). Must be in the pack's "
258
+ "`allowed-adapters` set when declared (legacy packs apply "
259
+ "the user-scope-capable / shipped-adapter subset by scope). "
260
+ "Mutually exclusive with --emit-install-routes at --scope "
261
+ f"repo. Shipped adapters: {', '.join(_shipped_for_cli)}."
262
+ ),
263
+ )
264
+ sp.add_argument(
265
+ "--emit-install-routes",
266
+ action="store_true",
267
+ help=(
268
+ "RFC-0012: catalogue-publishing opt-in — emit the legacy "
269
+ "dist-tree shape (`<repo>/claude-plugins/<pack>/`, "
270
+ "`<repo>/apm/<pack>/`) at `--scope repo` instead of the "
271
+ "default per-IDE projection. Bound to `--scope repo`; "
272
+ "mutually exclusive with `--adapter` at that scope."
273
+ ),
274
+ )
275
+ sp.set_defaults(func=_lazy("install"))
276
+
277
+ # --- validate --- (no --scope; schema + rails A/B/C)
278
+ sp = subparsers.add_parser(
279
+ "validate",
280
+ help="Validate a pack's pack.toml against the schemas; --strict for conformance.",
281
+ )
282
+ sp.add_argument("pack_path", help="Path to a pack directory containing pack.toml.")
283
+ sp.add_argument("--strict", action="store_true")
284
+ sp.set_defaults(func=_lazy("validate"))
285
+
286
+ # --- render ---
287
+ sp = subparsers.add_parser(
288
+ "render",
289
+ help="Render a pack to --output via the F-build pipeline (byte-identical to `make build`).",
290
+ )
291
+ sp.add_argument("pack_path", help="Path to a pack directory.")
292
+ sp.add_argument("--output", required=True)
293
+ sp.add_argument(
294
+ "--target",
295
+ help=(
296
+ "Optional adapter target (claude-code, kiro-ide, kiro-cli, "
297
+ "kiro (deprecated → kiro-ide), copilot, codex); "
298
+ "underscore form also accepted (claude_code); default: all."
299
+ ),
300
+ )
301
+ sp.add_argument(
302
+ "--self-host",
303
+ action="store_true",
304
+ help=(
305
+ "Treat --output as an adopter root: honour Tier-2 paths (write "
306
+ ".upstream.<ext> companions on collision rather than overwriting). "
307
+ "Requires a .agentbundle-state.toml at --output. Default: off "
308
+ "(wholesale rewrite, matching `make build` dist/ semantics)."
309
+ ),
310
+ )
311
+ sp.set_defaults(func=_lazy("render"))
312
+
313
+ # --- adapt ---
314
+ sp = subparsers.add_parser(
315
+ "adapt",
316
+ help="Resolve <adapt:NAME> markers in projected files; report .upstream.* companions.",
317
+ )
318
+ sp.add_argument("--values-from", help="TOML file with marker values.")
319
+ sp.add_argument("--ci", action="store_true",
320
+ help="Exit non-zero if any .upstream.<ext> companion remains on disk.")
321
+ sp.add_argument("--root", default=".")
322
+ sp.set_defaults(func=_lazy("adapt"))
323
+
324
+ # --- diff --- (--scope disambiguator)
325
+ sp = subparsers.add_parser(
326
+ "diff",
327
+ help="Diff the on-disk projection against a fresh render; non-zero on drift.",
328
+ )
329
+ sp.add_argument("pack_path", help="Path to the pack to diff against.")
330
+ sp.add_argument("--root", default=".")
331
+ sp.add_argument("--scope", choices=("repo", "user"))
332
+ sp.set_defaults(func=_lazy("diff"))
333
+
334
+ # --- upgrade --- (--scope disambiguator)
335
+ sp = subparsers.add_parser(
336
+ "upgrade",
337
+ help="Upgrade a pack or a single primitive within a pack.",
338
+ )
339
+ sp.add_argument("--pack", required=True)
340
+ sp.add_argument("--to", required=True, dest="to_version", help="Target pack version.")
341
+ sp.add_argument("--skill")
342
+ sp.add_argument("--agent")
343
+ sp.add_argument("--hook")
344
+ sp.add_argument("--seed")
345
+ sp.add_argument("--command")
346
+ sp.add_argument("catalogue", help="Catalogue URI to fetch the new version from.")
347
+ sp.add_argument("--root", default=".")
348
+ sp.add_argument("--scope", choices=("repo", "user"))
349
+ sp.set_defaults(func=_lazy("upgrade"))
350
+
351
+ # --- uninstall --- (--scope disambiguator)
352
+ sp = subparsers.add_parser(
353
+ "uninstall",
354
+ help="Uninstall a pack; remove Tier-1 files; preserve Tier-2 and Tier-3.",
355
+ )
356
+ sp.add_argument("--pack", required=True)
357
+ sp.add_argument("--root", default=".")
358
+ sp.add_argument("--scope", choices=("repo", "user"))
359
+ sp.set_defaults(func=_lazy("uninstall"))
360
+
361
+ # --- init-state --- (--scope selector; --migrate flag)
362
+ sp = subparsers.add_parser(
363
+ "init-state",
364
+ help="Hash an existing projection into .agentbundle-state.toml.",
365
+ )
366
+ # `--pack` is required for the hash-from-projection mode but not for
367
+ # `--migrate` (which is a whole-file rewrite); the handler enforces
368
+ # the relationship instead of argparse.
369
+ sp.add_argument("--pack")
370
+ sp.add_argument("--packs-dir", default="packs")
371
+ sp.add_argument("--root", default=".")
372
+ sp.add_argument(
373
+ "--migrate",
374
+ action="store_true",
375
+ help="Rewrite a v0.1 state file to v0.2 (RFC-0004). Idempotent.",
376
+ )
377
+ sp.add_argument("--scope", choices=("repo", "user"))
378
+ sp.set_defaults(func=_lazy("init_state"))
379
+
380
+ # --- config --- (post-pip-install user-scope settings)
381
+ sp = subparsers.add_parser(
382
+ "config",
383
+ help="Get or set adapter-scoped user settings.",
384
+ epilog=(
385
+ "User-config overrides scope.DEFAULT_ADAPTER on fresh "
386
+ "installs. CLI flags (e.g. install --adapter) and existing "
387
+ "install state still take precedence."
388
+ ),
389
+ )
390
+ sp.add_argument(
391
+ "config_action",
392
+ choices=("get", "set", "unset", "path"),
393
+ help="Action: get / set / unset / path.",
394
+ )
395
+ sp.add_argument("key", nargs="?", help="Setting key (e.g. adapter).")
396
+ sp.add_argument(
397
+ "value", nargs="?", help="Setting value (set only)."
398
+ )
399
+ sp.set_defaults(func=_lazy("config"))
400
+
401
+ # --- reconcile --- (read-only orphan reporter, RFC-0005 / T9)
402
+ # No --apply flag — the subcommand is report-only by design.
403
+ # `argparse`'s default "unrecognized argument" rejects --apply.
404
+ sp = subparsers.add_parser(
405
+ "reconcile",
406
+ help=(
407
+ "RFC-0005: read-only orphan reporter — walks Claude Code "
408
+ "settings.json and Kiro agent JSONs named in user-scope state, "
409
+ "reports entries the file/state pair disagrees on. Read-only; "
410
+ "no --apply flag."
411
+ ),
412
+ )
413
+ sp.add_argument("--scope", choices=("user",), default="user")
414
+ sp.set_defaults(func=_lazy("reconcile"))
415
+
416
+ return parser
417
+
418
+
419
+ def _lazy(module_name: str):
420
+ """Lazy import of `agentbundle.commands.<module_name>:run`.
421
+
422
+ Lets `agentbundle --version` and `--help` run before any command module
423
+ is imported — important because some command modules (e.g. `install`)
424
+ pull in `urllib.request`, `tarfile`, etc. that we don't want loaded for
425
+ a `--version` print. Also keeps unit-test import paths cheap.
426
+ """
427
+
428
+ def _runner(args: argparse.Namespace) -> int:
429
+ import importlib
430
+
431
+ mod = importlib.import_module(f"agentbundle.commands.{module_name}")
432
+ return int(mod.run(args))
433
+
434
+ return _runner
435
+
436
+
437
+ def _normalise_path_separators(args: argparse.Namespace) -> None:
438
+ """Rewrite backslashes to forward slashes on path-bearing
439
+ string attributes of the parsed namespace.
440
+
441
+ Done at the CLI boundary so a Windows operator typing
442
+ `agentbundle scaffold --output=packs\\core\\seeds` lands in the
443
+ same place as `--output=packs/core/seeds`. The path-jail check
444
+ and the Windows reserved-name guard both run on the normalised
445
+ form, so the two inputs share a single code path inside the CLI.
446
+
447
+ Only attribute names listed in `_PATH_BEARING_ATTRS` are touched —
448
+ that keeps a future content-string flag (regex, message body) from
449
+ being silently mangled. URI-shaped values (`git+https://…`) are
450
+ detected by `://` and left alone even when their attribute is in
451
+ the allow-list, because the same flag (`catalogue`) accepts both
452
+ local paths and URIs.
453
+ """
454
+ for key in _PATH_BEARING_ATTRS:
455
+ value = getattr(args, key, None)
456
+ if not isinstance(value, str):
457
+ continue
458
+ if "\\" not in value:
459
+ continue
460
+ if "://" in value:
461
+ continue
462
+ setattr(args, key, value.replace("\\", "/"))
463
+
464
+
465
+ def main(argv: Sequence[str] | None = None) -> int:
466
+ parser = _build_parser()
467
+ args = parser.parse_args(argv)
468
+ if not getattr(args, "func", None):
469
+ parser.print_help()
470
+ return 0
471
+ _normalise_path_separators(args)
472
+ # Load the user-scope config once at dispatch start and attach to
473
+ # `args._user_config`. Handlers that consume it read
474
+ # `getattr(args, "_user_config", None)` — see install.run / upgrade.run.
475
+ # `load_user_config()` is fail-soft (T1 contract): a malformed
476
+ # file emits a stderr warning and returns UserConfig(adapter=None)
477
+ # without raising, so `--help`, `config path`, and `config unset`
478
+ # all keep working when the file is broken.
479
+ from agentbundle.user_config import load_user_config
480
+
481
+ args._user_config = load_user_config()
482
+ return int(args.func(args))
483
+
484
+
485
+ if __name__ == "__main__":
486
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Subcommand modules — each exports `run(args) -> int`.
2
+
3
+ One module per F-cli subcommand. Modules are imported lazily by the CLI
4
+ dispatcher (`agentbundle.cli._lazy`) so `--version` / `--help` stay cheap.
5
+ """
@@ -0,0 +1,174 @@
1
+ """Cross-command helpers re-used by more than one subcommand.
2
+
3
+ This module is imported lazily (alongside its sibling command modules) so it
4
+ does not add startup cost to `--version` / `--help`. Only pure stdlib is
5
+ allowed here — see spec § Never do.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, NamedTuple
13
+
14
+ from agentbundle.version import SPEC_VERSION
15
+
16
+
17
+ class SeedDelivery(NamedTuple):
18
+ """One seed file's delivery outcome, returned by ``deliver_seeds``.
19
+
20
+ ``content`` is the *incoming* bytes the delivery used — for ``AGENTS.md``
21
+ that is the composed body+footer, not the raw seed file — so a caller that
22
+ records state hashes the same bytes the Tier comparison used. ``action`` is
23
+ one of ``"wrote"`` (Tier-1, absent on disk), ``"skipped"`` (already
24
+ byte-identical), or ``"companion"`` (Tier-2, adopter-edited → companion
25
+ dropped). ``companion_relpath`` is the POSIX ``*.upstream.<ext>`` path when
26
+ ``action == "companion"``, else ``None``.
27
+ """
28
+
29
+ relpath: str
30
+ content: bytes
31
+ action: str
32
+ companion_relpath: str | None
33
+
34
+
35
+ def _compose_agents_md_bytes(body: bytes, footer_path: Path) -> bytes:
36
+ """Compose the root ``AGENTS.md`` bytes from the body seed and optional footer.
37
+
38
+ Mirrors ``build/self_host.py:_compose_agents_md`` (lines 268-281): LF-normalise
39
+ and ensure a trailing newline on both halves, then concatenate. When the
40
+ ``_agents-footer.md`` fragment is absent the body passes through **byte-for-byte
41
+ unchanged** (no normalisation) so a footer-less pack delivers ``AGENTS.md`` verbatim.
42
+ """
43
+ if not footer_path.exists():
44
+ return body
45
+ text = body.decode("utf-8").replace("\r\n", "\n")
46
+ if text and not text.endswith("\n"):
47
+ text += "\n"
48
+ footer = footer_path.read_text(encoding="utf-8").replace("\r\n", "\n")
49
+ if footer and not footer.endswith("\n"):
50
+ footer += "\n"
51
+ return (text + footer).encode("utf-8")
52
+
53
+
54
+ def deliver_seeds(seeds_dir: Path, output: Path) -> list[SeedDelivery]:
55
+ """Deliver a pack's ``seeds/`` into ``output`` with Tier-1/2/3 safety.
56
+
57
+ For each file under ``seeds_dir`` (recursively):
58
+ - **Composition fragments** (name starts with ``_``, e.g.
59
+ ``_agents-footer.md``) are *not* delivered standalone — they are folded
60
+ into ``AGENTS.md`` instead (per ``CONVENTIONS.md`` §Pack source-of-truth split).
61
+ - **Absent on disk** → write the seed (Tier-1).
62
+ - **Present, content matches** → no-op (already in sync).
63
+ - **Present, content differs** → write a ``*.upstream.<ext>`` companion
64
+ next to the original; leave the original untouched (Tier-2).
65
+
66
+ Every write routes through ``safety.write_jailed`` / ``safety.write_companion``
67
+ with the **bare under-root jail** (no ``allowed_prefixes`` — seeds land at the
68
+ repo root and ``docs/``, outside the adapter projection prefixes). The caller
69
+ decides whether to record state; this helper never writes ``.agentbundle-state.toml``.
70
+
71
+ Raises ``safety.PathJailError`` if any seed relpath would escape ``output``;
72
+ the caller is expected to catch it, print to stderr, and exit 1.
73
+ """
74
+ import os
75
+
76
+ from agentbundle import safety
77
+
78
+ footer_path = seeds_dir / "_agents-footer.md"
79
+ # Guard the footer read too — ``_compose_agents_md_bytes`` reads
80
+ # ``footer_path`` directly, so a symlinked footer would be read through.
81
+ footer_ok = footer_path.is_file() and not footer_path.is_symlink()
82
+
83
+ # Defence-in-depth against a malicious pack exfiltrating a host file
84
+ # (``/etc/passwd``, ``~/.ssh/id_rsa``) into the adopter tree by symlinking
85
+ # a seed — never read *through* a pack-shipped symlink. We must not rely on
86
+ # ``Path.rglob``'s symlink posture: on Python 3.11/3.12 ``rglob`` recurses
87
+ # *into* symlinked directories (3.13 changed the default to
88
+ # ``recurse_symlinks=False``), so ``seeds/x -> /`` would surface real host
89
+ # files as non-symlink entries. ``os.walk(followlinks=False)`` never
90
+ # descends into a symlinked directory on any supported Python, and we also
91
+ # skip symlinked files — closing both the file and directory cases.
92
+ seed_files: list[Path] = []
93
+ for dirpath, _dirnames, filenames in os.walk(seeds_dir, followlinks=False):
94
+ for fname in filenames:
95
+ fpath = Path(dirpath) / fname
96
+ if fpath.is_symlink():
97
+ continue
98
+ seed_files.append(fpath)
99
+
100
+ results: list[SeedDelivery] = []
101
+ for seed_file in sorted(seed_files):
102
+ # Composition fragments are folded in, never delivered standalone.
103
+ if seed_file.name.startswith("_"):
104
+ continue
105
+ relpath = seed_file.relative_to(seeds_dir).as_posix()
106
+ content = seed_file.read_bytes()
107
+ if relpath == "AGENTS.md" and footer_ok:
108
+ content = _compose_agents_md_bytes(content, footer_path)
109
+
110
+ on_disk = output / relpath
111
+ if not on_disk.exists():
112
+ safety.write_jailed(output, relpath, content)
113
+ results.append(SeedDelivery(relpath, content, "wrote", None))
114
+ elif on_disk.read_bytes() == content:
115
+ results.append(SeedDelivery(relpath, content, "skipped", None))
116
+ else:
117
+ safety.write_companion(output, relpath, content)
118
+ companion = safety.companion_path(Path(relpath)).as_posix()
119
+ results.append(SeedDelivery(relpath, content, "companion", companion))
120
+ return results
121
+
122
+
123
+ def check_spec_version_gate(pack_toml: dict[str, Any]) -> int | None:
124
+ """Refuse if the pack's declared spec major version differs from ours.
125
+
126
+ Returns:
127
+ None — caller may proceed (pack does not gate, or majors agree).
128
+ 1 — caller should `return` this immediately; refusal already
129
+ printed to stderr with both versions named.
130
+
131
+ The pack declares its version under `[pack.adapter-contract] version`;
132
+ the CLI's version comes from `agentbundle.version.SPEC_VERSION` (read
133
+ at import time from the bundled `adapter.toml`). AC #14 in the spec
134
+ requires every subcommand that consumes a pack manifest to invoke
135
+ this gate before any I/O the pack would drive — uniform refusal, no
136
+ partial behaviour.
137
+ """
138
+ from agentbundle.config import pack_spec_version # local import avoids circular
139
+
140
+ declared = pack_spec_version(pack_toml)
141
+ if declared is None:
142
+ return None
143
+
144
+ cli_major = _major(SPEC_VERSION)
145
+ pack_major = _major(declared)
146
+ if cli_major != pack_major:
147
+ print(
148
+ f"error: pack declares adapter-contract version {declared!r} "
149
+ f"(major {pack_major}), but this CLI ships spec version {SPEC_VERSION!r} "
150
+ f"(major {cli_major}); refusing to operate on incompatible pack.",
151
+ file=sys.stderr,
152
+ )
153
+ return 1
154
+ return None
155
+
156
+
157
+ def load_pack_and_gate(pack_path: Path) -> tuple[dict[str, Any], int] | tuple[dict[str, Any], None]:
158
+ """Load a pack's `pack.toml` and apply the spec-version gate.
159
+
160
+ Returns `(pack_toml, None)` on accept and `(pack_toml, 1)` on refusal.
161
+ The pack_toml is returned in both cases so the caller can introspect
162
+ even on refusal — useful for `validate` which reports schema errors
163
+ and version errors together.
164
+ """
165
+ from agentbundle.config import load_pack_toml
166
+
167
+ pack_toml = load_pack_toml(pack_path / "pack.toml")
168
+ return pack_toml, check_spec_version_gate(pack_toml)
169
+
170
+
171
+ def _major(version: str) -> str:
172
+ """Return the major component of a version string like '0.1' or 'v2.0'."""
173
+ v = version.lstrip("v")
174
+ return v.split(".")[0]