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
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,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]
|