agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,250 @@
1
+ """Tests for the stdlib JSON-Schema subset validator (T1a)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ from agentbundle.build.validate import validate
12
+
13
+ REPO_ROOT = Path(__file__).resolve().parents[5]
14
+ SCHEMA_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.schema.json"
15
+
16
+
17
+ class TypeKeywordTests(unittest.TestCase):
18
+ def test_object_accepts_dict(self) -> None:
19
+ self.assertEqual(validate({}, {"type": "object"}), [])
20
+
21
+ def test_object_rejects_list(self) -> None:
22
+ errors = validate([], {"type": "object"})
23
+ self.assertTrue(errors)
24
+ self.assertIn("expected object", errors[0])
25
+
26
+ def test_string_accepts_string(self) -> None:
27
+ self.assertEqual(validate("x", {"type": "string"}), [])
28
+
29
+ def test_string_rejects_integer(self) -> None:
30
+ errors = validate(7, {"type": "string"})
31
+ self.assertTrue(errors)
32
+
33
+ def test_integer_rejects_boolean(self) -> None:
34
+ # Python bool is a subclass of int; the validator must reject it.
35
+ errors = validate(True, {"type": "integer"})
36
+ self.assertTrue(errors)
37
+ self.assertIn("boolean", errors[0])
38
+
39
+ def test_boolean_accepts_true(self) -> None:
40
+ self.assertEqual(validate(True, {"type": "boolean"}), [])
41
+
42
+ def test_boolean_rejects_integer(self) -> None:
43
+ errors = validate(1, {"type": "boolean"})
44
+ self.assertTrue(errors)
45
+
46
+ def test_array_accepts_list(self) -> None:
47
+ self.assertEqual(validate([1, 2], {"type": "array"}), [])
48
+
49
+ def test_array_rejects_string(self) -> None:
50
+ errors = validate("nope", {"type": "array"})
51
+ self.assertTrue(errors)
52
+
53
+
54
+ class RequiredKeywordTests(unittest.TestCase):
55
+ def test_required_present(self) -> None:
56
+ schema = {"type": "object", "required": ["a"]}
57
+ self.assertEqual(validate({"a": 1}, schema), [])
58
+
59
+ def test_required_missing(self) -> None:
60
+ schema = {"type": "object", "required": ["a"]}
61
+ errors = validate({}, schema)
62
+ self.assertTrue(errors)
63
+ self.assertIn("missing required property", errors[0])
64
+
65
+
66
+ class EnumKeywordTests(unittest.TestCase):
67
+ def test_enum_accepts_member(self) -> None:
68
+ self.assertEqual(validate("a", {"enum": ["a", "b"]}), [])
69
+
70
+ def test_enum_rejects_non_member(self) -> None:
71
+ errors = validate("c", {"enum": ["a", "b"]})
72
+ self.assertTrue(errors)
73
+ self.assertIn("not in enum", errors[0])
74
+
75
+
76
+ class PatternKeywordTests(unittest.TestCase):
77
+ def test_pattern_match(self) -> None:
78
+ self.assertEqual(validate("abc123", {"pattern": "^[a-z]+[0-9]+$"}), [])
79
+
80
+ def test_pattern_mismatch(self) -> None:
81
+ errors = validate("nope!", {"pattern": "^[a-z]+$"})
82
+ self.assertTrue(errors)
83
+
84
+
85
+ class ItemsKeywordTests(unittest.TestCase):
86
+ def test_items_homogeneous(self) -> None:
87
+ schema = {"type": "array", "items": {"type": "string"}}
88
+ self.assertEqual(validate(["a", "b"], schema), [])
89
+
90
+ def test_items_rejects_wrong_type(self) -> None:
91
+ schema = {"type": "array", "items": {"type": "string"}}
92
+ errors = validate(["a", 1], schema)
93
+ self.assertTrue(errors)
94
+
95
+
96
+ class PropertiesAndAdditionalTests(unittest.TestCase):
97
+ def test_additional_properties_false(self) -> None:
98
+ schema = {
99
+ "type": "object",
100
+ "properties": {"a": {"type": "string"}},
101
+ "additionalProperties": False,
102
+ }
103
+ errors = validate({"a": "x", "b": "y"}, schema)
104
+ self.assertTrue(errors)
105
+ self.assertIn("additional property", errors[0])
106
+
107
+ def test_additional_properties_default_allows(self) -> None:
108
+ schema = {"type": "object", "properties": {"a": {"type": "string"}}}
109
+ self.assertEqual(validate({"a": "x", "b": "y"}, schema), [])
110
+
111
+
112
+ class SchemaJsonSelfValidationTests(unittest.TestCase):
113
+ """The shipped adapter.schema.json must load and validate at least one minimal
114
+ contract — a smoke test for T1a's authored schema."""
115
+
116
+ def test_schema_loads(self) -> None:
117
+ schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
118
+ self.assertEqual(schema.get("type"), "object")
119
+
120
+ def test_schema_accepts_minimal_contract(self) -> None:
121
+ schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
122
+ minimal = {
123
+ "contract": {"version": "0.1"},
124
+ "primitive": {
125
+ "skill": {"source-path": ".apm/skills/"},
126
+ "agent": {"source-path": ".apm/agents/"},
127
+ "hook-body": {"source-path": ".apm/hooks/"},
128
+ "hook-wiring": {"source-path": ".apm/hook-wiring/"},
129
+ "command": {"source-path": ".apm/commands/"},
130
+ },
131
+ "adapter": {
132
+ "claude-code": {
133
+ "projection": [
134
+ {
135
+ "primitive": "skill",
136
+ "mode": "direct-directory",
137
+ "target-path": ".claude/skills/",
138
+ "on-conflict": "prompt-then-preserve",
139
+ }
140
+ ]
141
+ }
142
+ },
143
+ }
144
+ errors = validate(minimal, schema)
145
+ self.assertEqual(errors, [], f"schema rejected minimal contract: {errors}")
146
+
147
+ def test_schema_rejects_unknown_mode(self) -> None:
148
+ schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
149
+ bad = {
150
+ "contract": {"version": "0.1"},
151
+ "primitive": {
152
+ "skill": {"source-path": ".apm/skills/"},
153
+ "agent": {"source-path": ".apm/agents/"},
154
+ "hook-body": {"source-path": ".apm/hooks/"},
155
+ "hook-wiring": {"source-path": ".apm/hook-wiring/"},
156
+ "command": {"source-path": ".apm/commands/"},
157
+ },
158
+ "adapter": {
159
+ "claude-code": {
160
+ "projection": [
161
+ {
162
+ "primitive": "skill",
163
+ "mode": "bogus-mode",
164
+ "target-path": ".claude/skills/",
165
+ "on-conflict": "prompt-then-preserve",
166
+ }
167
+ ]
168
+ }
169
+ },
170
+ }
171
+ errors = validate(bad, schema)
172
+ self.assertTrue(errors, "schema accepted an unknown projection mode")
173
+
174
+
175
+ class CliValidateSubcommandTests(unittest.TestCase):
176
+ """`python -m agentbundle.build validate <path>` smoke test.
177
+
178
+ Uses subprocess so the test exercises the actual argparse wiring.
179
+ Does not rely on adapter.toml existing (T1b authors that); writes
180
+ a temp TOML and validates it against the shipped schema.
181
+ """
182
+
183
+ def test_validate_subcommand_exits_zero_on_minimal_contract(self) -> None:
184
+ import tempfile
185
+
186
+ toml_text = """
187
+ [contract]
188
+ version = "0.1"
189
+
190
+ [primitive.skill]
191
+ source-path = ".apm/skills/"
192
+
193
+ [primitive.agent]
194
+ source-path = ".apm/agents/"
195
+
196
+ [primitive.hook-body]
197
+ source-path = ".apm/hooks/"
198
+
199
+ [primitive.hook-wiring]
200
+ source-path = ".apm/hook-wiring/"
201
+
202
+ [primitive.command]
203
+ source-path = ".apm/commands/"
204
+
205
+ [[adapter.claude-code.projection]]
206
+ primitive = "skill"
207
+ mode = "direct-directory"
208
+ target-path = ".claude/skills/"
209
+ on-conflict = "prompt-then-preserve"
210
+ """
211
+ with tempfile.NamedTemporaryFile(
212
+ mode="w", suffix=".toml", delete=False
213
+ ) as tmp:
214
+ tmp.write(toml_text)
215
+ tmp_path = tmp.name
216
+
217
+ result = subprocess.run(
218
+ [sys.executable, "-m", "agentbundle.build", "validate", tmp_path],
219
+ capture_output=True,
220
+ text=True,
221
+ cwd=REPO_ROOT,
222
+ )
223
+ self.assertEqual(
224
+ result.returncode,
225
+ 0,
226
+ f"validate subcommand failed: stderr={result.stderr}",
227
+ )
228
+
229
+ def test_help_exits_zero(self) -> None:
230
+ result = subprocess.run(
231
+ [sys.executable, "-m", "agentbundle.build", "--help"],
232
+ capture_output=True,
233
+ text=True,
234
+ cwd=REPO_ROOT,
235
+ )
236
+ self.assertEqual(result.returncode, 0)
237
+ self.assertIn("validate", result.stdout)
238
+
239
+ def test_validate_help_exits_zero(self) -> None:
240
+ result = subprocess.run(
241
+ [sys.executable, "-m", "agentbundle.build", "validate", "--help"],
242
+ capture_output=True,
243
+ text=True,
244
+ cwd=REPO_ROOT,
245
+ )
246
+ self.assertEqual(result.returncode, 0)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ unittest.main()
@@ -0,0 +1,141 @@
1
+ """Stdlib-only JSON-Schema subset validator.
2
+
3
+ The build pipeline depends on stdlib only (per spec § Boundaries —
4
+ Never do), so we cannot import a JSON-Schema library. This module
5
+ ships the subset RFC-0001 + spec AC #6 commit to:
6
+
7
+ Supported keywords:
8
+ - `type`: one of "object", "array", "string", "integer", "boolean"
9
+ - `properties`: object → schema map (only meaningful when type=object)
10
+ - `required`: list of property names (only meaningful when type=object)
11
+ - `enum`: list of allowed scalar values
12
+ - `pattern`: regex string applied to strings (via `re`)
13
+ - `items`: schema applied element-wise (only meaningful when type=array)
14
+ - `additionalProperties`: bool or schema (only meaningful when type=object)
15
+ - `minItems`: integer; array must have at least this many items
16
+ - `contains`: subschema; array must have at least one item matching it
17
+ - `if` / `then` / `else`: conditional subschemas (all three applied to
18
+ the same instance as the parent). RFC-0004's pack.schema.json uses
19
+ this trio to express "v0.2 packs require `[pack.install]`" and
20
+ "default-scope ∈ allowed-scopes."
21
+
22
+ Unsupported by design: `$ref`, `$defs`, `oneOf`, `anyOf`, `allOf`,
23
+ `not`, `format`, `minimum`/`maximum`, `minLength`/`maxLength`. If the
24
+ contract grows a need, an RFC amends this subset; the validator does
25
+ not silently expand.
26
+
27
+ `validate(instance, schema)` returns a list of error strings — empty
28
+ list means valid. Callers decide how to surface (the CLI's `validate`
29
+ subcommand prints them to stderr and exits non-zero).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from typing import Any
36
+
37
+ _TYPE_PYTHON = {
38
+ "object": dict,
39
+ "array": list,
40
+ "string": str,
41
+ "integer": int,
42
+ "boolean": bool,
43
+ }
44
+
45
+
46
+ def validate(instance: Any, schema: dict, path: str = "$") -> list[str]:
47
+ """Validate `instance` against `schema`. Return a list of error strings."""
48
+ errors: list[str] = []
49
+ if not isinstance(schema, dict):
50
+ return [f"{path}: schema is not an object"]
51
+
52
+ expected_type = schema.get("type")
53
+ if expected_type is not None:
54
+ if expected_type not in _TYPE_PYTHON:
55
+ return [f"{path}: unsupported type {expected_type!r}"]
56
+ py_type = _TYPE_PYTHON[expected_type]
57
+ # bool is a subclass of int in Python; reject bool when integer is expected
58
+ # and reject int when boolean is expected.
59
+ if expected_type == "integer" and isinstance(instance, bool):
60
+ errors.append(f"{path}: expected integer, got boolean")
61
+ return errors
62
+ if expected_type == "boolean" and not isinstance(instance, bool):
63
+ errors.append(f"{path}: expected boolean, got {type(instance).__name__}")
64
+ return errors
65
+ if not isinstance(instance, py_type):
66
+ errors.append(
67
+ f"{path}: expected {expected_type}, got {type(instance).__name__}"
68
+ )
69
+ return errors
70
+
71
+ if "enum" in schema:
72
+ if instance not in schema["enum"]:
73
+ errors.append(
74
+ f"{path}: value {instance!r} not in enum {schema['enum']!r}"
75
+ )
76
+
77
+ if "pattern" in schema and isinstance(instance, str):
78
+ if re.search(schema["pattern"], instance) is None:
79
+ errors.append(
80
+ f"{path}: value {instance!r} does not match pattern {schema['pattern']!r}"
81
+ )
82
+
83
+ # `required`, `properties`, `additionalProperties` apply whenever the
84
+ # instance is a dict — per JSON-Schema 2020-12, these keywords are
85
+ # vacuously true for non-object instances. Gating on `expected_type ==
86
+ # "object"` (the previous shape) caused subschemas with no `type`
87
+ # declaration (e.g. RFC-0004's `if`/`then` blocks) to silently skip
88
+ # their constraints — surprising callers and breaking the cross-field
89
+ # `default-scope ∈ allowed-scopes` invariant. Stay safe-by-default:
90
+ # apply when the instance matches.
91
+ if isinstance(instance, dict):
92
+ for required_key in schema.get("required", []):
93
+ if required_key not in instance:
94
+ errors.append(f"{path}: missing required property {required_key!r}")
95
+ properties = schema.get("properties", {})
96
+ for key, value in instance.items():
97
+ subpath = f"{path}.{key}"
98
+ if key in properties:
99
+ errors.extend(validate(value, properties[key], subpath))
100
+ else:
101
+ additional = schema.get("additionalProperties", True)
102
+ if additional is False:
103
+ errors.append(f"{path}: additional property {key!r} not allowed")
104
+ elif isinstance(additional, dict):
105
+ errors.extend(validate(value, additional, subpath))
106
+
107
+ if isinstance(instance, list):
108
+ item_schema = schema.get("items")
109
+ if isinstance(item_schema, dict):
110
+ for index, element in enumerate(instance):
111
+ errors.extend(validate(element, item_schema, f"{path}[{index}]"))
112
+ min_items = schema.get("minItems")
113
+ # bool is a subclass of int in Python — accept only true integers.
114
+ if isinstance(min_items, int) and not isinstance(min_items, bool):
115
+ if len(instance) < min_items:
116
+ errors.append(
117
+ f"{path}: array has {len(instance)} item(s), minItems={min_items}"
118
+ )
119
+ contains_schema = schema.get("contains")
120
+ if isinstance(contains_schema, dict):
121
+ if not any(
122
+ not validate(element, contains_schema, f"{path}[{index}]")
123
+ for index, element in enumerate(instance)
124
+ ):
125
+ errors.append(
126
+ f"{path}: no item matches the 'contains' subschema"
127
+ )
128
+
129
+ # Conditional subschemas — applied last so type/required/enum errors on
130
+ # the instance surface before any conditional branch fires. The 'if'
131
+ # subschema is evaluated silently (its errors are not surfaced); only
132
+ # the chosen branch's errors propagate. Per JSON-Schema 2020-12,
133
+ # missing 'then' or 'else' is a no-op for that branch.
134
+ if "if" in schema and isinstance(schema["if"], dict):
135
+ if_errors = validate(instance, schema["if"], path)
136
+ branch_key = "then" if not if_errors else "else"
137
+ branch_schema = schema.get(branch_key)
138
+ if isinstance(branch_schema, dict):
139
+ errors.extend(validate(instance, branch_schema, path))
140
+
141
+ return errors
@@ -0,0 +1,164 @@
1
+ """Catalogue URI resolver — T5 deliverable, reused by T6/T8/T11/T12.
2
+
3
+ Accepts:
4
+ - Local relative or absolute paths.
5
+ - ``git+https://github.com/<owner>/<repo>[@<ref>]``
6
+
7
+ For ``git+https://`` URIs the resolver:
8
+ 1. Parses owner, repo, and optional ref.
9
+ 2. Constructs a GitHub archive URL (tag, branch, or SHA — tried in
10
+ that order by a light heuristic: tags contain only ``v`` + semver
11
+ chars or no slash; SHAs are exactly 40 hex chars; everything else
12
+ is a branch).
13
+ 3. Fetches with ``urllib.request.urlopen`` — no subprocess, no git.
14
+ 4. Extracts with ``tarfile`` into a per-call tempdir and returns the
15
+ inner ``<repo>-<ref>/`` directory.
16
+
17
+ ``git+ssh://`` URIs raise ``CatalogueError`` immediately — SSH is
18
+ deferred to v1.1.
19
+
20
+ Unreachable URLs raise ``CatalogueError`` with the tarball URL in the
21
+ message so the caller can report exactly what was attempted.
22
+
23
+ No subprocess calls anywhere in this module.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import atexit
29
+ import re
30
+ import shutil
31
+ import tarfile
32
+ import tempfile
33
+ import urllib.error
34
+ import urllib.request
35
+ from pathlib import Path
36
+
37
+ _SSH_PREFIX = "git+ssh://"
38
+ _HTTPS_PREFIX = "git+https://"
39
+
40
+ # Match git+https://github.com/<owner>/<repo>[@<ref>]
41
+ # Group 1: owner, Group 2: repo (no .git suffix), Group 3: ref (optional)
42
+ _HTTPS_RE = re.compile(
43
+ r"^git\+https://github\.com/([^/]+)/([^/@]+?)(?:\.git)?(?:@([^@]+))?$"
44
+ )
45
+
46
+ # A SHA is exactly 40 lowercase hex digits (or 7–40 for abbreviated SHAs;
47
+ # we accept the full pattern only to keep the heuristic simple).
48
+ _SHA_RE = re.compile(r"^[0-9a-f]{7,40}$")
49
+
50
+
51
+ class CatalogueError(ValueError):
52
+ """Raised when a catalogue URI cannot be resolved."""
53
+
54
+
55
+ def resolve_catalogue(uri: str) -> Path:
56
+ """Resolve *uri* to a local directory rooted at the catalogue.
57
+
58
+ Returns a ``Path`` to the local directory. For ``git+https://`` URIs
59
+ the path lives inside a per-call tempdir registered with ``atexit``
60
+ so it's removed at process exit — see ``_resolve_https``. Callers
61
+ must not assume the directory survives past process termination.
62
+ """
63
+ if uri.startswith(_SSH_PREFIX):
64
+ raise CatalogueError(
65
+ "SSH git URLs deferred to v1.1; use https or local path."
66
+ )
67
+
68
+ if uri.startswith(_HTTPS_PREFIX):
69
+ return _resolve_https(uri)
70
+
71
+ # Local path — relative or absolute.
72
+ return Path(uri)
73
+
74
+
75
+ def _resolve_https(uri: str) -> Path:
76
+ m = _HTTPS_RE.match(uri)
77
+ if not m:
78
+ raise CatalogueError(
79
+ f"Cannot parse git+https URI: {uri!r}. "
80
+ "Expected format: git+https://github.com/<owner>/<repo>[@<ref>]"
81
+ )
82
+ owner, repo, ref = m.group(1), m.group(2), m.group(3)
83
+ if not ref:
84
+ ref = "main"
85
+
86
+ tarball_url = _github_archive_url(owner, repo, ref)
87
+ tmpdir = Path(tempfile.mkdtemp(prefix="agentbundle-catalogue-"))
88
+ # Best-effort cleanup at process exit — atexit handlers run on normal
89
+ # interpreter shutdown; for crash paths the OS reaps /tmp eventually.
90
+ atexit.register(shutil.rmtree, str(tmpdir), True)
91
+ _fetch_and_extract(tarball_url, tmpdir)
92
+ # The GitHub archive extracts to <repo>-<ref>/ (with '/' → '-' in SHAs).
93
+ inner = _find_inner_dir(tmpdir)
94
+ return inner
95
+
96
+
97
+ def _ref_type(ref: str) -> str:
98
+ """Heuristically classify a ref as 'tag', 'sha', or 'branch'.
99
+
100
+ The plan specifies: try tag, then branch, then SHA order — but we
101
+ need to pick exactly one URL at construction time because the caller
102
+ doesn't retry across URL forms (the tarball fetch either succeeds or
103
+ raises ``CatalogueError``).
104
+
105
+ Heuristic:
106
+ - Exactly 40 lowercase hex chars → SHA.
107
+ - Looks like a version tag (optional 'v' + digits/dots, e.g. v1.0
108
+ or 1.0.0) → tag.
109
+ - Anything else → branch.
110
+
111
+ This matches the plan's examples: ``v1.0`` → tag, ``main`` → branch,
112
+ ``deadbeef`` (7 chars) or a full 40-char SHA → sha.
113
+
114
+ Abbreviated SHAs (7–39 chars, all hex) are treated as SHA because
115
+ that's the most likely intent and ``archive/<sha>`` accepts prefixes.
116
+ """
117
+ if _SHA_RE.match(ref):
118
+ return "sha"
119
+ # Version tag pattern: optional 'v', one or more numeric segments
120
+ if re.match(r"^v?\d+(\.\d+)*$", ref):
121
+ return "tag"
122
+ return "branch"
123
+
124
+
125
+ def _github_archive_url(owner: str, repo: str, ref: str) -> str:
126
+ rtype = _ref_type(ref)
127
+ if rtype == "tag":
128
+ return f"https://github.com/{owner}/{repo}/archive/refs/tags/{ref}.tar.gz"
129
+ if rtype == "branch":
130
+ return f"https://github.com/{owner}/{repo}/archive/refs/heads/{ref}.tar.gz"
131
+ # SHA
132
+ return f"https://github.com/{owner}/{repo}/archive/{ref}.tar.gz"
133
+
134
+
135
+ def _fetch_and_extract(url: str, dest: Path) -> None:
136
+ try:
137
+ with urllib.request.urlopen(url) as resp: # noqa: S310 — url is assembled from parsed owner/repo/ref
138
+ with tarfile.open(fileobj=resp, mode="r|gz") as tf:
139
+ # filter="data" rejects unsafe members (absolute paths, ..
140
+ # links, devices, setuid bits) — Python 3.12+ default but
141
+ # explicit for 3.11 compatibility and to silence the 3.14
142
+ # DeprecationWarning. Path-jail is belt; this is braces.
143
+ tf.extractall(path=dest, filter="data") # noqa: S202
144
+ except urllib.error.URLError as exc:
145
+ raise CatalogueError(
146
+ f"Failed to fetch catalogue archive: {url} — {exc.reason}"
147
+ ) from exc
148
+ except tarfile.TarError as exc:
149
+ raise CatalogueError(
150
+ f"Failed to extract tarball from {url}: {exc}"
151
+ ) from exc
152
+
153
+
154
+ def _find_inner_dir(tmpdir: Path) -> Path:
155
+ """Return the single top-level directory inside *tmpdir*.
156
+
157
+ GitHub archives always produce exactly one top-level directory
158
+ (``<repo>-<ref>/``). If the extraction produced something else,
159
+ return *tmpdir* itself so callers still have something to work with.
160
+ """
161
+ children = [p for p in tmpdir.iterdir() if p.is_dir()]
162
+ if len(children) == 1:
163
+ return children[0]
164
+ return tmpdir