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/scope.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Scope resolution + user-scope root expansion (RFC-0004 T17).
|
|
2
|
+
|
|
3
|
+
Two helpers:
|
|
4
|
+
|
|
5
|
+
- :func:`resolve` resolves the install scope per the spec precedence
|
|
6
|
+
rule **CLI flag > pack default > built-in ``"repo"``**, and refuses
|
|
7
|
+
when the resolved value is not in the pack's ``allowed-scopes`` with
|
|
8
|
+
:class:`ScopeRefused`. Used by every write-capable subcommand once
|
|
9
|
+
the pack manifest is loaded.
|
|
10
|
+
|
|
11
|
+
- :func:`resolve_user_root` runs ``pathlib.Path.expanduser("~")`` once
|
|
12
|
+
and refuses with :class:`UserScopeUnresolvable` when the result is
|
|
13
|
+
the literal ``"~"`` (expansion failed) or ``"/"`` (corporate sandbox
|
|
14
|
+
with ``$HOME=/``). The CLI's top-level handler maps the exception
|
|
15
|
+
to the documented stderr text ``cannot resolve user scope: $HOME
|
|
16
|
+
unset or invalid``.
|
|
17
|
+
|
|
18
|
+
The exceptions carry just enough context for the formatter at the call
|
|
19
|
+
site to render the spec's exact stderr without per-call-site
|
|
20
|
+
introspection: ``ScopeRefused`` holds the pack name, the requested
|
|
21
|
+
scope, and the declared set; ``UserScopeUnresolvable`` holds the
|
|
22
|
+
diagnostic value (``"~"`` or ``"/"``).
|
|
23
|
+
|
|
24
|
+
This module is import-cheap (no I/O at import time) so the CLI's
|
|
25
|
+
``--version`` print path stays fast.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import tomllib
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Iterable
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
# TYPE_CHECKING-only — does NOT execute at runtime, so the
|
|
37
|
+
# `user_config.py → scope.py` import direction stays acyclic
|
|
38
|
+
# (only the type annotation in configured_adapter() needs the
|
|
39
|
+
# name resolved, and PEP 563 keeps it a string until anyone
|
|
40
|
+
# calls get_type_hints()).
|
|
41
|
+
from agentbundle.user_config import UserConfig
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# The spec's only legal scope values; ``global`` is deliberately absent
|
|
45
|
+
# (RFC-0004 § Alternatives considered §6). Keep this single-sourced so
|
|
46
|
+
# argparse's `choices=` and the runtime resolver agree.
|
|
47
|
+
LEGAL_SCOPES: frozenset[str] = frozenset({"repo", "user"})
|
|
48
|
+
|
|
49
|
+
# RFC-0011 / pack-allowed-adapters introduced this constant for the
|
|
50
|
+
# greenfield-fallback default at user scope; RFC-0012 widens it to
|
|
51
|
+
# cover both scopes (the constant is the single per-distribution
|
|
52
|
+
# default, consulted at both `--scope user` and `--scope repo`).
|
|
53
|
+
# Downstream catalogues can monkey-patch this constant at startup to
|
|
54
|
+
# flip the default for their own distribution (e.g. an enterprise
|
|
55
|
+
# rebrand sets `DEFAULT_ADAPTER = "kiro"`).
|
|
56
|
+
DEFAULT_ADAPTER: str = "claude-code"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Deprecated alias for ``DEFAULT_ADAPTER`` — kept for one release
|
|
60
|
+
# (removed in agentbundle 0.2.0 per RFC-0012 § *Module-level constant
|
|
61
|
+
# rename*). PEP 562 module-level ``__getattr__`` fires only on access
|
|
62
|
+
# of the deprecated name; direct access to ``DEFAULT_ADAPTER`` is
|
|
63
|
+
# warning-free.
|
|
64
|
+
def __getattr__(name: str) -> str: # pragma: no cover - exercised via tests
|
|
65
|
+
if name == "DEFAULT_USER_SCOPE_ADAPTER":
|
|
66
|
+
import warnings
|
|
67
|
+
|
|
68
|
+
warnings.warn(
|
|
69
|
+
"DEFAULT_USER_SCOPE_ADAPTER is deprecated; use "
|
|
70
|
+
"DEFAULT_ADAPTER. Removed in agentbundle 0.2.0.",
|
|
71
|
+
DeprecationWarning,
|
|
72
|
+
stacklevel=2,
|
|
73
|
+
)
|
|
74
|
+
return DEFAULT_ADAPTER
|
|
75
|
+
raise AttributeError(
|
|
76
|
+
f"module 'agentbundle.scope' has no attribute {name!r}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ScopeRefused(Exception):
|
|
81
|
+
"""Raised when the resolved scope is not in the pack's allowed-scopes.
|
|
82
|
+
|
|
83
|
+
Attributes carry the three pieces the spec stderr names; the CLI's
|
|
84
|
+
top-level handler formats them as
|
|
85
|
+
``<pack>: scope '<requested>' not in allowed-scopes <declared-set>``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
pack_name: str,
|
|
91
|
+
requested: str,
|
|
92
|
+
allowed: Iterable[str],
|
|
93
|
+
) -> None:
|
|
94
|
+
self.pack_name = pack_name
|
|
95
|
+
self.requested = requested
|
|
96
|
+
# Preserve the declared order — adopters reading the message
|
|
97
|
+
# want to see what *they* wrote, not a re-sorted set.
|
|
98
|
+
self.allowed = list(allowed)
|
|
99
|
+
super().__init__(
|
|
100
|
+
f"{pack_name}: scope {requested!r} not in allowed-scopes {self.allowed}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class UserScopeUnresolvable(Exception):
|
|
105
|
+
"""Raised when ``expanduser('~')`` cannot produce a usable user root.
|
|
106
|
+
|
|
107
|
+
The two failure modes the spec names:
|
|
108
|
+
|
|
109
|
+
- expansion returned literal ``"~"`` (no home directory at all),
|
|
110
|
+
- expansion returned ``"/"`` ($HOME=/ — corporate sandbox).
|
|
111
|
+
|
|
112
|
+
The CLI's top-level handler formats this as
|
|
113
|
+
``cannot resolve user scope: $HOME unset or invalid``.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve(
|
|
118
|
+
cli_flag: str | None,
|
|
119
|
+
pack_install: dict | None,
|
|
120
|
+
builtin_default: str = "repo",
|
|
121
|
+
*,
|
|
122
|
+
pack_name: str = "<pack>",
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Resolve the install scope per the spec precedence rule.
|
|
125
|
+
|
|
126
|
+
Precedence: **CLI flag > pack ``default-scope`` > built-in ``repo``.**
|
|
127
|
+
The resolved value must be in the pack's ``allowed-scopes`` (resolved
|
|
128
|
+
from the install table, with the implied default
|
|
129
|
+
``[default-scope]`` when ``allowed-scopes`` is omitted).
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
cli_flag: the ``--scope`` argparse value (``None`` if omitted).
|
|
133
|
+
pack_install: the ``[pack.install]`` table from the pack's TOML
|
|
134
|
+
(``None`` when the pack is v0.1 — see RFC-0004 § *Install-
|
|
135
|
+
scope dimension*; treated as ``{}``).
|
|
136
|
+
builtin_default: the fallback when neither CLI nor pack declares.
|
|
137
|
+
pack_name: the pack name for refusal text; the caller passes the
|
|
138
|
+
real name when known so :class:`ScopeRefused` carries the
|
|
139
|
+
spec-shaped message.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The resolved scope (``"repo"`` or ``"user"``).
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
:class:`ScopeRefused` if the resolved value is not in
|
|
146
|
+
``allowed-scopes``.
|
|
147
|
+
"""
|
|
148
|
+
install = pack_install if isinstance(pack_install, dict) else {}
|
|
149
|
+
pack_default = install.get("default-scope")
|
|
150
|
+
if not isinstance(pack_default, str) or pack_default not in LEGAL_SCOPES:
|
|
151
|
+
pack_default = builtin_default
|
|
152
|
+
|
|
153
|
+
raw_allowed = install.get("allowed-scopes")
|
|
154
|
+
if isinstance(raw_allowed, list) and raw_allowed:
|
|
155
|
+
allowed = [s for s in raw_allowed if isinstance(s, str)]
|
|
156
|
+
else:
|
|
157
|
+
# Implied default: `[default-scope]` per RFC-0004 § *Per-pack
|
|
158
|
+
# default and allowance*. v0.1 packs (no install table) fall
|
|
159
|
+
# through to `[builtin_default]` which is `"repo"`.
|
|
160
|
+
allowed = [pack_default]
|
|
161
|
+
|
|
162
|
+
requested = cli_flag if isinstance(cli_flag, str) else pack_default
|
|
163
|
+
|
|
164
|
+
if requested not in allowed:
|
|
165
|
+
raise ScopeRefused(pack_name, requested, allowed)
|
|
166
|
+
return requested
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_user_root(home: Path | None = None) -> Path:
|
|
170
|
+
"""Expand the user-scope root once at scope-resolution time.
|
|
171
|
+
|
|
172
|
+
Calls ``Path.expanduser("~")`` (or accepts a stub ``home`` for
|
|
173
|
+
tests) and refuses when the result is the literal ``"~"`` (no
|
|
174
|
+
home directory) or ``"/"`` ($HOME=/ — corporate sandbox).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The resolved absolute :class:`Path` for ``~``.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
:class:`UserScopeUnresolvable` on either documented failure.
|
|
181
|
+
"""
|
|
182
|
+
if home is None:
|
|
183
|
+
try:
|
|
184
|
+
expanded = Path("~").expanduser()
|
|
185
|
+
except RuntimeError as exc:
|
|
186
|
+
# Python 3.13+ raises RuntimeError when expanduser cannot
|
|
187
|
+
# resolve (no $HOME and no pwd entry); older Pythons would
|
|
188
|
+
# return the literal "~". Both signals normalise to "user
|
|
189
|
+
# scope is unresolvable" — wrap.
|
|
190
|
+
raise UserScopeUnresolvable(
|
|
191
|
+
"expanduser('~') could not determine the home directory"
|
|
192
|
+
) from exc
|
|
193
|
+
else:
|
|
194
|
+
expanded = Path(home)
|
|
195
|
+
if str(expanded) == "~":
|
|
196
|
+
raise UserScopeUnresolvable("expanduser returned literal '~'")
|
|
197
|
+
# Normalise before the root-check so a hostile `$HOME=/etc/..` is
|
|
198
|
+
# caught (it resolves to `/`). We use Path.resolve(strict=False)
|
|
199
|
+
# so a non-existent home doesn't raise — that's a separate failure
|
|
200
|
+
# the downstream caller can surface.
|
|
201
|
+
try:
|
|
202
|
+
normalised = expanded.resolve(strict=False)
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise UserScopeUnresolvable(
|
|
205
|
+
f"could not resolve $HOME path {expanded}: {exc}"
|
|
206
|
+
) from exc
|
|
207
|
+
if str(normalised) == "/":
|
|
208
|
+
raise UserScopeUnresolvable("expanduser resolved to '/' ($HOME=/)")
|
|
209
|
+
return normalised
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Adapter-contract introspection (RFC-0011 / pack-allowed-adapters)
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
#
|
|
216
|
+
# Three pure-data helpers that derive their answers from the bundled
|
|
217
|
+
# `agentbundle/_data/adapter.toml` shipped inside the wheel. The schema
|
|
218
|
+
# validator (`commands/validate.py`) and the CLI argparse setup
|
|
219
|
+
# (`cli.py`) both consume these so the schema enum, the argparse
|
|
220
|
+
# `choices=` list, and the runtime resolver all read from one place.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _load_bundled_contract() -> dict:
|
|
224
|
+
"""Read `_data/adapter.toml` from the installed package.
|
|
225
|
+
|
|
226
|
+
Kept private; callers go through the three high-level helpers
|
|
227
|
+
below. Re-reads on every call (cheap; ~5KB TOML parse) so a
|
|
228
|
+
monkeypatch in tests can swap the bundled file without leaking
|
|
229
|
+
a cached parse. Uses the project's zipapp-safe reader instead of
|
|
230
|
+
`Path(__file__).parent` — `__file__` inside a zipapp isn't a real
|
|
231
|
+
filesystem path and `Path.read_text()` raises NotADirectoryError.
|
|
232
|
+
"""
|
|
233
|
+
from agentbundle.build.main import _read_bundled
|
|
234
|
+
|
|
235
|
+
return tomllib.loads(_read_bundled("adapter.toml"))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def shipped_adapters_from_contract() -> tuple[str, ...]:
|
|
239
|
+
"""Return every adapter declared in `[adapter.<name>]` blocks of
|
|
240
|
+
the bundled contract, alphabetic-sorted.
|
|
241
|
+
|
|
242
|
+
Consumers: the install CLI's argparse `--adapter` `choices=` list.
|
|
243
|
+
Sorted-tuple shape so `argparse --help` renders stably across
|
|
244
|
+
runs and Python versions.
|
|
245
|
+
"""
|
|
246
|
+
contract = _load_bundled_contract()
|
|
247
|
+
return tuple(sorted(contract.get("adapter", {}).keys()))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def user_scope_capable_adapters_from_contract() -> tuple[str, ...]:
|
|
251
|
+
"""Return adapters that declare `[adapter.<name>.scope].user` —
|
|
252
|
+
the set that can target user scope at all.
|
|
253
|
+
|
|
254
|
+
Consumers: the schema validator's `allowed-adapters` cross-field
|
|
255
|
+
refusal; the install handler's `--adapter` user-scope-capability
|
|
256
|
+
check. Sorted-tuple for the same stability reason as above.
|
|
257
|
+
"""
|
|
258
|
+
contract = _load_bundled_contract()
|
|
259
|
+
capable = []
|
|
260
|
+
for name, block in contract.get("adapter", {}).items():
|
|
261
|
+
scope = block.get("scope")
|
|
262
|
+
if isinstance(scope, dict) and "user" in scope:
|
|
263
|
+
capable.append(name)
|
|
264
|
+
return tuple(sorted(capable))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# Contract versions that pre-date hook-wiring (RFC-0005). The
|
|
268
|
+
# `_kiro_target_adapters` rail and any future hook-related code path
|
|
269
|
+
# checks this; a literal-set check would silently break on the next
|
|
270
|
+
# contract bump (v0.7+), so the predicate is the load-bearing form.
|
|
271
|
+
_PRE_HOOK_WIRING_CONTRACT_VERSIONS: frozenset[str] = frozenset({"0.1", "0.2"})
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def configured_adapter(user_config: "UserConfig | None") -> str | None:
|
|
275
|
+
"""Report the user-configured adapter when set and known by the
|
|
276
|
+
bundled adapter contract.
|
|
277
|
+
|
|
278
|
+
Returns `user_config.adapter` when set AND in
|
|
279
|
+
`shipped_adapters_from_contract()`; returns `None` otherwise.
|
|
280
|
+
|
|
281
|
+
Scope-agnostic and pack-agnostic by design. Scope-capability and
|
|
282
|
+
pack-`allowed_adapters` enforcement live in the resolver's
|
|
283
|
+
pre-flight (`_resolve_target_adapter` in `commands/install.py`),
|
|
284
|
+
not here. The `None` return is load-bearing: it lets the resolver
|
|
285
|
+
distinguish "user configured something usable" from "nothing
|
|
286
|
+
configured, fall through to existing defaults" so that the probe
|
|
287
|
+
and `DEFAULT_ADAPTER` cascade preserved for users who never ran
|
|
288
|
+
`agentbundle config set`.
|
|
289
|
+
|
|
290
|
+
The `shipped` check is the contract-drift guard: an adapter
|
|
291
|
+
dropped from the contract since the user wrote their config
|
|
292
|
+
returns None here and falls through to the constant — same
|
|
293
|
+
fail-soft contract as `read_user_config`'s warning path. The
|
|
294
|
+
annotation is a string under `from __future__ import annotations`,
|
|
295
|
+
so this module does NOT import `agentbundle.user_config` at
|
|
296
|
+
runtime — that keeps the cycle broken
|
|
297
|
+
(`user_config.py → scope.py` is the only direction).
|
|
298
|
+
"""
|
|
299
|
+
if user_config is None or user_config.adapter is None:
|
|
300
|
+
return None
|
|
301
|
+
if user_config.adapter not in shipped_adapters_from_contract():
|
|
302
|
+
return None
|
|
303
|
+
return user_config.adapter
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def contract_supports_hook_wiring(version: str | None) -> bool:
|
|
307
|
+
"""True for any contract version that ships hook-wiring as a
|
|
308
|
+
first-class primitive (v0.3 and later).
|
|
309
|
+
|
|
310
|
+
The semantic predicate replaces the literal `version != "0.3"`
|
|
311
|
+
check that lived at `validate.py:379` pre-RFC-0011 — that check
|
|
312
|
+
silently dropped v0.6+ packs from the kiro-targeting rail and
|
|
313
|
+
would re-break on v0.7. None / unknown values return False
|
|
314
|
+
(conservative: a pack with no declared contract version is
|
|
315
|
+
treated as pre-hook-wiring).
|
|
316
|
+
"""
|
|
317
|
+
if not isinstance(version, str):
|
|
318
|
+
return False
|
|
319
|
+
return version not in _PRE_HOOK_WIRING_CONTRACT_VERSIONS
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""User-scope CLI config: read, write, unset of `~/.../agentbundle/config.toml`.
|
|
2
|
+
|
|
3
|
+
The file holds adapter-scoped settings the user can change post-pip-install
|
|
4
|
+
without monkey-patching `scope.DEFAULT_ADAPTER`. See
|
|
5
|
+
`docs/specs/agentbundle-config-subcommand/spec.md` for the contract.
|
|
6
|
+
|
|
7
|
+
Module surface:
|
|
8
|
+
- `UserConfig` — frozen dataclass; today only `adapter: str | None`.
|
|
9
|
+
- `_user_config_path(*, platform, env, home)` — pure path resolver.
|
|
10
|
+
- `read_user_config(path)` — fail-soft loader (warns on malformed
|
|
11
|
+
TOML or unknown adapter value; never raises).
|
|
12
|
+
- `load_user_config()` — convenience: `read_user_config(_user_config_path())`.
|
|
13
|
+
- `write_setting(path, key, value)` — validates and writes.
|
|
14
|
+
- `unset_setting(path, key)` — removes and (when empty) deletes the file.
|
|
15
|
+
|
|
16
|
+
`UserConfig` is referenced from `agentbundle.scope.configured_adapter` only
|
|
17
|
+
as a forward-declared annotation (`from __future__ import annotations`), so
|
|
18
|
+
`scope.py` does not import this module at runtime. That keeps the cycle
|
|
19
|
+
broken: `user_config.py → scope.py` (for the shipped-adapter sanity check
|
|
20
|
+
in `read_user_config`) is the only direction.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tomllib
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Mapping
|
|
31
|
+
|
|
32
|
+
from agentbundle.config import _emit_basic_string
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_KNOWN_KEYS: tuple[str, ...] = ("adapter",)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class UserConfig:
|
|
40
|
+
"""Parsed `[settings]` table from the user-scope config file.
|
|
41
|
+
|
|
42
|
+
`None` means "the key was absent from the file, the file did not
|
|
43
|
+
exist, or the on-disk value failed validation." Callers cannot
|
|
44
|
+
distinguish these three from the dataclass alone; the loader emits
|
|
45
|
+
a stderr warning when validation drops a value.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
adapter: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Path resolution
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _user_config_path(
|
|
57
|
+
*,
|
|
58
|
+
platform: str | None = None,
|
|
59
|
+
env: Mapping[str, str] | None = None,
|
|
60
|
+
home: Path | None = None,
|
|
61
|
+
) -> Path:
|
|
62
|
+
"""Resolve the OS-conventional path to `<dir>/agentbundle/config.toml`.
|
|
63
|
+
|
|
64
|
+
Pure: no disk I/O. Defaults to `sys.platform`, `os.environ`,
|
|
65
|
+
`Path.home()` for production callers; tests pass explicit values.
|
|
66
|
+
"""
|
|
67
|
+
p = platform if platform is not None else sys.platform
|
|
68
|
+
e: Mapping[str, str] = env if env is not None else os.environ
|
|
69
|
+
h = home if home is not None else Path.home()
|
|
70
|
+
if p == "darwin":
|
|
71
|
+
base = h / "Library" / "Application Support"
|
|
72
|
+
elif p == "win32":
|
|
73
|
+
appdata = e.get("APPDATA")
|
|
74
|
+
base = Path(appdata) if appdata else h / "AppData" / "Roaming"
|
|
75
|
+
else:
|
|
76
|
+
# Linux + every other POSIX falls back to XDG.
|
|
77
|
+
xdg = e.get("XDG_CONFIG_HOME")
|
|
78
|
+
base = Path(xdg) if xdg else h / ".config"
|
|
79
|
+
return base / "agentbundle" / "config.toml"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Read / load
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_user_config(path: Path) -> UserConfig:
|
|
88
|
+
"""Load the config file fail-soft.
|
|
89
|
+
|
|
90
|
+
A missing file returns `UserConfig()`. A malformed file emits a
|
|
91
|
+
one-line stderr warning and returns `UserConfig()`. An on-disk
|
|
92
|
+
`adapter` value not in `shipped_adapters_from_contract()` emits
|
|
93
|
+
a one-line stderr warning listing the admissible names and
|
|
94
|
+
returns `UserConfig()`.
|
|
95
|
+
|
|
96
|
+
The fail-soft contract is load-bearing: `cli.py:main()` calls
|
|
97
|
+
`load_user_config()` on every invocation including `--help` and
|
|
98
|
+
`config path`, so a broken file must not block recovery commands.
|
|
99
|
+
"""
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return UserConfig()
|
|
102
|
+
try:
|
|
103
|
+
with path.open("rb") as fh:
|
|
104
|
+
parsed = tomllib.load(fh)
|
|
105
|
+
except tomllib.TOMLDecodeError as exc:
|
|
106
|
+
print(
|
|
107
|
+
f"agentbundle: warning: user config at {path} is malformed "
|
|
108
|
+
f"({exc}); ignoring. Edit or remove the file with "
|
|
109
|
+
f"`agentbundle config unset adapter` or hand-edit "
|
|
110
|
+
f"`{path}`.",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
113
|
+
return UserConfig()
|
|
114
|
+
|
|
115
|
+
settings = parsed.get("settings", {})
|
|
116
|
+
if not isinstance(settings, dict):
|
|
117
|
+
# An on-disk [settings] that's not a table is structurally
|
|
118
|
+
# invalid — same fail-soft contract.
|
|
119
|
+
print(
|
|
120
|
+
f"agentbundle: warning: user config at {path} has a "
|
|
121
|
+
f"non-table `settings` entry; ignoring.",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
return UserConfig()
|
|
125
|
+
|
|
126
|
+
raw_adapter = settings.get("adapter")
|
|
127
|
+
if raw_adapter is None:
|
|
128
|
+
return UserConfig()
|
|
129
|
+
if not isinstance(raw_adapter, str):
|
|
130
|
+
print(
|
|
131
|
+
f"agentbundle: warning: user config at {path} has a "
|
|
132
|
+
f"non-string `adapter` value ({type(raw_adapter).__name__}); "
|
|
133
|
+
f"ignoring.",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
return UserConfig()
|
|
137
|
+
|
|
138
|
+
# Validate against the shipped adapter contract. Late import to
|
|
139
|
+
# avoid a circular import at module load.
|
|
140
|
+
from agentbundle.scope import shipped_adapters_from_contract
|
|
141
|
+
|
|
142
|
+
shipped = shipped_adapters_from_contract()
|
|
143
|
+
if raw_adapter not in shipped:
|
|
144
|
+
print(
|
|
145
|
+
f"agentbundle: warning: user config at {path} has an "
|
|
146
|
+
f"adapter value ({raw_adapter!r}) that is not in the "
|
|
147
|
+
f"current adapter contract. Admissible: {sorted(shipped)}. "
|
|
148
|
+
f"Falling back to the built-in default; either edit the "
|
|
149
|
+
f"file or run `agentbundle config set adapter <name>`.",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
return UserConfig()
|
|
153
|
+
|
|
154
|
+
return UserConfig(adapter=raw_adapter)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_user_config() -> UserConfig:
|
|
158
|
+
"""Convenience: `read_user_config(_user_config_path())`.
|
|
159
|
+
|
|
160
|
+
`cli.py:main()` calls this once per invocation and attaches the
|
|
161
|
+
result to `args._user_config` before dispatching.
|
|
162
|
+
"""
|
|
163
|
+
return read_user_config(_user_config_path())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Write / unset (shared invariants)
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_known_or_raise(path: Path) -> dict[str, Any]:
|
|
172
|
+
"""Parse the file as TOML and refuse any shape this writer can't
|
|
173
|
+
safely round-trip.
|
|
174
|
+
|
|
175
|
+
The fail-loud guards: any non-`[settings]` top-level table or any
|
|
176
|
+
non-string value under `[settings]` (including nested tables, which
|
|
177
|
+
`tomllib` materialises as dict values). Both cases raise with the
|
|
178
|
+
same "future setting type not yet supported" message; the caller is
|
|
179
|
+
expected to leave the file untouched.
|
|
180
|
+
"""
|
|
181
|
+
if not path.exists():
|
|
182
|
+
return {}
|
|
183
|
+
with path.open("rb") as fh:
|
|
184
|
+
parsed = tomllib.load(fh)
|
|
185
|
+
for top_key, value in parsed.items():
|
|
186
|
+
if top_key != "settings":
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"agentbundle: future setting table not yet supported: "
|
|
189
|
+
f"[{top_key}] in {path}. Hand-edit or remove the file."
|
|
190
|
+
)
|
|
191
|
+
if not isinstance(value, dict):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"agentbundle: future setting type not yet supported: "
|
|
194
|
+
f"non-table `settings` in {path}."
|
|
195
|
+
)
|
|
196
|
+
for k, v in value.items():
|
|
197
|
+
if not isinstance(v, str):
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"agentbundle: future setting type not yet supported: "
|
|
200
|
+
f"non-string value under [settings] (key {k!r}, "
|
|
201
|
+
f"type {type(v).__name__}) in {path}. Hand-edit or "
|
|
202
|
+
f"remove the file."
|
|
203
|
+
)
|
|
204
|
+
return parsed
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _emit_settings(settings: dict[str, str]) -> bytes:
|
|
208
|
+
"""Render `{key: str_value}` into a deterministic TOML file body.
|
|
209
|
+
|
|
210
|
+
Keys are emitted in sorted order; values go through
|
|
211
|
+
`_emit_basic_string` (same helper `agentbundle.config` uses for
|
|
212
|
+
state-file writes). Empty `settings` returns empty bytes — the
|
|
213
|
+
caller decides whether to write or delete.
|
|
214
|
+
"""
|
|
215
|
+
if not settings:
|
|
216
|
+
return b""
|
|
217
|
+
lines = ["[settings]"]
|
|
218
|
+
for k in sorted(settings):
|
|
219
|
+
lines.append(f"{k} = {_emit_basic_string(settings[k])}")
|
|
220
|
+
lines.append("") # trailing newline
|
|
221
|
+
return "\n".join(lines).encode("utf-8")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _validate_key_value(key: str, value: str) -> None:
|
|
225
|
+
"""Refuse unknown keys; for `adapter`, refuse unknown values."""
|
|
226
|
+
if key not in _KNOWN_KEYS:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"agentbundle: unknown setting {key!r}. Known settings: "
|
|
229
|
+
f"{list(_KNOWN_KEYS)}."
|
|
230
|
+
)
|
|
231
|
+
if key == "adapter":
|
|
232
|
+
from agentbundle.scope import shipped_adapters_from_contract
|
|
233
|
+
|
|
234
|
+
shipped = shipped_adapters_from_contract()
|
|
235
|
+
if value not in shipped:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"agentbundle: unknown adapter {value!r}. Admissible: "
|
|
238
|
+
f"{sorted(shipped)}."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def write_setting(path: Path, key: str, value: str) -> None:
|
|
243
|
+
"""Validate `(key, value)`, then write the file (creating parents).
|
|
244
|
+
|
|
245
|
+
Idempotent on repeat with the same value (same bytes are written).
|
|
246
|
+
Raises `ValueError` on validation failure or on a file shape this
|
|
247
|
+
writer refuses; the on-disk file is not mutated in those cases.
|
|
248
|
+
"""
|
|
249
|
+
_validate_key_value(key, value)
|
|
250
|
+
parsed = _parse_known_or_raise(path)
|
|
251
|
+
settings = dict(parsed.get("settings", {}))
|
|
252
|
+
settings[key] = value
|
|
253
|
+
body = _emit_settings(settings)
|
|
254
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
path.write_bytes(body)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def unset_setting(path: Path, key: str) -> None:
|
|
259
|
+
"""Remove `key` from `[settings]`. Delete the file if it becomes
|
|
260
|
+
empty AND no other top-level tables are present.
|
|
261
|
+
|
|
262
|
+
Idempotent: unsetting a missing key (or operating on a missing
|
|
263
|
+
file) is a no-op. Raises `ValueError` on an unknown key or on a
|
|
264
|
+
file shape this writer refuses; the on-disk file is not mutated
|
|
265
|
+
in those cases.
|
|
266
|
+
"""
|
|
267
|
+
if key not in _KNOWN_KEYS:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"agentbundle: unknown setting {key!r}. Known settings: "
|
|
270
|
+
f"{list(_KNOWN_KEYS)}."
|
|
271
|
+
)
|
|
272
|
+
if not path.exists():
|
|
273
|
+
return
|
|
274
|
+
parsed = _parse_known_or_raise(path)
|
|
275
|
+
settings = dict(parsed.get("settings", {}))
|
|
276
|
+
if key not in settings:
|
|
277
|
+
return
|
|
278
|
+
del settings[key]
|
|
279
|
+
other_tables = [k for k in parsed if k != "settings"]
|
|
280
|
+
if not settings and not other_tables:
|
|
281
|
+
path.unlink()
|
|
282
|
+
return
|
|
283
|
+
body = _emit_settings(settings)
|
|
284
|
+
path.write_bytes(body)
|
agentbundle/version.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Version constants — CLI version and spec version both pinned at import time.
|
|
2
|
+
|
|
3
|
+
Spec version is parsed from the bundled canonical `adapter.toml` once, at
|
|
4
|
+
module import; mutating the on-disk file after import has no effect on
|
|
5
|
+
`SPEC_VERSION`. This pins the contract version the running CLI ships against
|
|
6
|
+
to the value present when the process started — which is the value users see
|
|
7
|
+
in `agentbundle --version` and the value every pack-version-mismatch refusal
|
|
8
|
+
cites.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import tomllib
|
|
14
|
+
from importlib.resources import files
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CLI_VERSION = "0.1.0"
|
|
18
|
+
|
|
19
|
+
_HERE = Path(__file__).resolve().parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_bundled_adapter_toml_text() -> str:
|
|
23
|
+
"""Read the canonical adapter.toml as text.
|
|
24
|
+
|
|
25
|
+
Resolution:
|
|
26
|
+
1. `agentbundle._data/adapter.toml` via `importlib.resources` —
|
|
27
|
+
works inside a `zipapp`, a `pip install`, and a dev checkout
|
|
28
|
+
that has `_data/` populated.
|
|
29
|
+
2. `<repo>/docs/contracts/adapter.toml` — dev-checkout fallback
|
|
30
|
+
for the (rare) case where `_data/` is missing in the source
|
|
31
|
+
tree (mostly during initial scaffolding).
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
resource = files("agentbundle").joinpath("_data/adapter.toml")
|
|
35
|
+
if resource.is_file():
|
|
36
|
+
return resource.read_text(encoding="utf-8")
|
|
37
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
fallback = _HERE.parent.parent.parent / "docs" / "contracts" / "adapter.toml"
|
|
41
|
+
return fallback.read_text(encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_spec_version() -> str:
|
|
45
|
+
data = tomllib.loads(_read_bundled_adapter_toml_text())
|
|
46
|
+
return data["contract"]["version"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
SPEC_VERSION: str = _read_spec_version()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentbundle
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Reference CLI and distribution pipeline for the agent-ready-repo adapter contract.
|
|
5
|
+
Author-email: eugenelim <eugenelim@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0 OR MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eugenelim/agent-ready-repo
|
|
8
|
+
Project-URL: Source, https://github.com/eugenelim/agent-ready-repo
|
|
9
|
+
Project-URL: Documentation, https://github.com/eugenelim/agent-ready-repo/blob/main/docs/guides/how-to/install-agentbundle-from-clone.md
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# agentbundle
|
|
20
|
+
|
|
21
|
+
Runtime library and reference CLI for the
|
|
22
|
+
[agent-ready-repo](https://github.com/eugenelim/agent-ready-repo) adapter
|
|
23
|
+
contract. Ships the `agentbundle` console script — install, validate,
|
|
24
|
+
adapt, and inspect packs — plus the build pipeline (`agentbundle.build`)
|
|
25
|
+
that projects pack sources into adapter-shaped trees.
|
|
26
|
+
|
|
27
|
+
As of 0.2.0, credential resolution lives in the build-projected
|
|
28
|
+
`credentials_shim` sibling that the `credential-brokers` pack drops
|
|
29
|
+
alongside each `auth: creds` consumer skill's `scripts/`. The
|
|
30
|
+
`agentbundle.credentials` module that previous releases (0.1.x)
|
|
31
|
+
exposed has been removed; consumers import `from .credentials_shim
|
|
32
|
+
import …` against the projected sibling instead. See the
|
|
33
|
+
`credential-broker-contract` spec and this package's `CHANGELOG.md`
|
|
34
|
+
for the migration recipe.
|
|
35
|
+
|
|
36
|
+
See the [top-level README](https://github.com/eugenelim/agent-ready-repo#readme)
|
|
37
|
+
for install routes, the pack catalogue, and the adapter contract.
|