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/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.