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,1099 @@
1
+ """Canonical stdlib-only install-marker writer for the Claude-plugins and APM install routes.
2
+
3
+ This script is invoked by a ``SessionStart`` hook derived by ``agentbundle build``
4
+ into every pack's projected output:
5
+ - ``dist/claude-plugins/<pack>/.claude-plugin/plugin.json`` — claude-plugins route
6
+ - ``dist/apm/<pack>/.apm/hooks/install-marker.json`` — APM route
7
+
8
+ It detects first-install-or-update and writes a ``[[packs-installed]]`` entry
9
+ to the scope-correct ``.adapt-install-marker.toml`` file so the existing core
10
+ session-start nudge and ``/adapt-to-project`` skill can consume it.
11
+
12
+ Specs:
13
+ docs/specs/claude-plugins-install-route/spec.md (route = "claude-plugins")
14
+ docs/specs/apm-install-route-parity/spec.md (route = "apm"; --install-route flag)
15
+
16
+ CLI:
17
+ ``--install-route {claude-plugins,apm}`` is *required*. The build pipeline bakes
18
+ the value into the projected hook ``command`` at projection time for both routes;
19
+ the writer does no runtime route-sniffing. ``"cli"`` is *not* a valid choice — the
20
+ CLI route uses ``agentbundle install._append_install_marker`` directly and never
21
+ invokes this template.
22
+
23
+ Environment variables consumed under ``--install-route claude-plugins``:
24
+ CLAUDE_PLUGIN_ROOT — path to the pack's root in the Claude-plugins cache.
25
+ CLAUDE_PLUGIN_DATA — path to the pack's per-session data directory (hash file lives here).
26
+ HOME — user home directory (user-scope marker path).
27
+ CLAUDE_PROJECT_DIR — (optional) path to the current project directory; when absent,
28
+ local and project scope checks are skipped.
29
+
30
+ Environment variables consumed under ``--install-route apm`` (precedence-resolved):
31
+ CLAUDE_PLUGIN_DATA — APM's Claude Code target rewrites ``${PLUGIN_ROOT}`` to
32
+ ``${CLAUDE_PLUGIN_ROOT}`` *and* sets this; used directly when set.
33
+ PLUGIN_ROOT — APM's generic per-target token; ``${PLUGIN_ROOT}/.data`` for the
34
+ hash file when ``${CLAUDE_PLUGIN_DATA}`` is unset.
35
+ CURSOR_PLUGIN_ROOT — APM's Cursor target equivalent; ``${CURSOR_PLUGIN_ROOT}/.data``
36
+ when both above are unset.
37
+ CLAUDE_PLUGIN_ROOT — APM's Claude Code pack-root token (when set, used as pack root).
38
+ HOME — user home directory (user-scope marker path; also used to
39
+ detect ``writer-under-$HOME`` scope).
40
+ Scope detection under APM is by writer's own resolved ``__file__`` path containment
41
+ under ``cwd`` (→ repo scope) or ``HOME`` (→ user scope), not by ``enabledPlugins``.
42
+
43
+ Exit codes:
44
+ 0 — success (marker written, or warm-cache skip, or refused-and-warned on scope mismatch),
45
+ or argparse rejected the flag (the latter is exit 2 per argparse's defaults).
46
+ 1 — marker write failed (hash file NOT written; next session retries).
47
+ 2 — ``argparse`` parse error (missing or invalid ``--install-route``).
48
+ """
49
+
50
+ import argparse
51
+ import datetime
52
+ import hashlib
53
+ import json
54
+ import os
55
+ import pathlib
56
+ import sys
57
+ import tempfile
58
+ import tomllib
59
+ from datetime import timezone
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Vendored pack-name / pack-version shape rules — copied from
64
+ # agentbundle.commands.install._PACK_NAME_RE / _PACK_VERSION_RE.
65
+ # Source path: packages/agentbundle/agentbundle/commands/install.py
66
+ # Keep in sync with the source; the regexes are the CLI's canonical gate for
67
+ # pack-name shape and must match exactly (same pattern string, same flags).
68
+ # Security Concern 7: without this guard a pack with `name = "core\nevil"` in
69
+ # pack.toml would pass unchecked and land phantom TOML lines in the marker.
70
+ # ---------------------------------------------------------------------------
71
+ import re as _re
72
+
73
+ _PACK_NAME_RE = _re.compile(r"^[a-z0-9][a-z0-9-]*$")
74
+ _PACK_VERSION_RE = _re.compile(
75
+ r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$"
76
+ )
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Vendored helper — copied from agentbundle.config._emit_basic_string.
80
+ # Source path: packages/agentbundle/agentbundle/config.py
81
+ # Keep in sync with the source; any security fix there must be applied here too.
82
+ # The self-host drift gate (make build-check) asserts byte-identical output
83
+ # across the fixed attack corpus — do NOT silently alter this function.
84
+ # Note: the source raises ConfigError; here we raise ValueError (stdlib-only
85
+ # constraint means no ConfigError import). Behaviour is otherwise identical.
86
+ # ---------------------------------------------------------------------------
87
+
88
+ # Control characters that TOML 1.0 § Strings forbids unescaped inside a
89
+ # basic-string. Everything in U+0000..U+001F except `\t` (which has a
90
+ # short escape), plus U+007F. The `\uXXXX` long-form covers them all.
91
+ _TOML_SHORT_ESCAPES = {
92
+ "\b": "\\b",
93
+ "\t": "\\t",
94
+ "\n": "\\n",
95
+ "\f": "\\f",
96
+ "\r": "\\r",
97
+ '"': '\\"',
98
+ "\\": "\\\\",
99
+ }
100
+
101
+
102
+ def _emit_basic_string(value: str) -> str:
103
+ """Serialise *value* as a TOML 1.0 basic-string literal (incl. quotes).
104
+
105
+ Every write-path that interpolates a pack-sourced string into TOML output
106
+ routes through here. The grammar matches what ``tomllib`` will accept:
107
+ short escapes for ``\\b \\t \\n \\f \\r \\" \\\\``, ``\\uXXXX`` for any
108
+ other control char (U+0000..U+001F and U+007F), and verbatim emission
109
+ for everything else (including multi-byte UTF-8).
110
+
111
+ Returns the *quoted* form ``"...escaped..."`` so callers write
112
+ ``key = {_emit_basic_string(v)}`` without re-adding quotes.
113
+
114
+ Raises ``ValueError`` if *value* is not a string.
115
+ """
116
+ if not isinstance(value, str):
117
+ raise ValueError(
118
+ f"basic-string position expects str, got {type(value).__name__}"
119
+ )
120
+ chunks: list[str] = ['"']
121
+ for ch in value:
122
+ short = _TOML_SHORT_ESCAPES.get(ch)
123
+ if short is not None:
124
+ chunks.append(short)
125
+ elif ord(ch) < 0x20 or ord(ch) == 0x7F:
126
+ chunks.append(f"\\u{ord(ch):04X}")
127
+ else:
128
+ chunks.append(ch)
129
+ chunks.append('"')
130
+ return "".join(chunks)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Vendored path-jail helpers — copied from agentbundle.safety.
135
+ # Source path: packages/agentbundle/agentbundle/safety.py
136
+ # Keep in sync with the source; any security fix there must be applied here too.
137
+ # Vendored inline because this writer is stdlib-only (no agentbundle import).
138
+ # ---------------------------------------------------------------------------
139
+
140
+ # Windows reserves these device names regardless of extension.
141
+ _WINDOWS_RESERVED_NAMES = frozenset(
142
+ ["CON", "PRN", "AUX", "NUL"]
143
+ + [f"COM{i}" for i in range(1, 10)]
144
+ + [f"LPT{i}" for i in range(1, 10)]
145
+ )
146
+
147
+ # Characters Windows refuses in filenames (backslash omitted — treated as separator).
148
+ _WINDOWS_FORBIDDEN_CHARS = frozenset('<>:"|?*')
149
+
150
+
151
+ def _assert_under(target: pathlib.Path, jail: pathlib.Path) -> None:
152
+ """Refuse if ``target`` resolves outside ``jail``.
153
+
154
+ ``jail`` is expected to be a pre-resolved path (from ``_marker_path``), so
155
+ we only re-resolve the *target* here. This closes the TOCTOU window: the
156
+ jail value is fixed at probe time and cannot be redirected by a symlink
157
+ introduced between the probe and this check.
158
+
159
+ Raises ``ValueError`` if ``os.path.realpath(target)`` is not under
160
+ ``jail``. Uses ``relative_to`` on resolved paths to foil ``..``
161
+ traversal and symlink escape.
162
+ """
163
+ resolved_target = pathlib.Path(os.path.realpath(target))
164
+ # jail is already resolved; do not call os.path.realpath on it again.
165
+ try:
166
+ resolved_target.relative_to(jail)
167
+ except ValueError:
168
+ raise ValueError(
169
+ f"install-marker: marker path {resolved_target} escapes the per-scope jail "
170
+ f"{jail}; refusing write"
171
+ )
172
+
173
+
174
+ def _assert_portable_name(component: str) -> None:
175
+ """Refuse Windows-poisonous filename components.
176
+
177
+ Checks three classes (all OSes — pack content travels to Windows adopters):
178
+ 1. Reserved device names (CON/PRN/AUX/NUL/COM1-9/LPT1-9), case-insensitive,
179
+ matched on the pre-extension stem (Windows treats ``CON.txt`` as ``CON``).
180
+ 2. Names ending in ``.`` or `` `` (Windows strips both silently).
181
+ 3. Names containing ``< > : " | ? *`` (illegal in Windows filenames).
182
+ 4. Names containing control characters (U+0000..U+001F or U+007F).
183
+
184
+ Raises ``ValueError`` with a one-line message naming the component.
185
+ """
186
+ if not component or component in (".", ".."):
187
+ return
188
+ for ch in component:
189
+ if ch in _WINDOWS_FORBIDDEN_CHARS:
190
+ raise ValueError(
191
+ f"install-marker: refusing path component with forbidden character "
192
+ f"{ch!r}: {component!r}"
193
+ )
194
+ if ord(ch) < 0x20 or ord(ch) == 0x7F:
195
+ raise ValueError(
196
+ f"install-marker: refusing path component with control character "
197
+ f"U+{ord(ch):04X}: {component!r}"
198
+ )
199
+ if component.endswith(".") or component.endswith(" "):
200
+ raise ValueError(
201
+ f"install-marker: refusing path component with trailing dot or space: "
202
+ f"{component!r}"
203
+ )
204
+ stem = component.split(".", 1)[0]
205
+ if stem.upper() in _WINDOWS_RESERVED_NAMES:
206
+ raise ValueError(
207
+ f"install-marker: refusing Windows-reserved device name "
208
+ f"{stem!r} in component {component!r}"
209
+ )
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Scope detection helpers
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _detect_origin(
218
+ *,
219
+ plugin_name: str,
220
+ home: pathlib.Path,
221
+ project_dir: pathlib.Path | None,
222
+ ) -> str | None:
223
+ """Walk the three ``enabledPlugins`` settings files in precedence order.
224
+
225
+ Returns the most-specific origin scope on opt-in: ``"local"``,
226
+ ``"project"``, or ``"user"``. Returns ``None`` for fall-through
227
+ (pack is not enabled at any scope).
228
+
229
+ Precedence: local → project → user (most-specific wins).
230
+ Missing file, malformed JSON, absent ``enabledPlugins`` key, or
231
+ ``enabledPlugins`` present but not a JSON array are each treated as
232
+ "not opted in at that scope" and fall through.
233
+ """
234
+ candidates: list[tuple[str, pathlib.Path]] = []
235
+
236
+ if project_dir is not None:
237
+ candidates.append(("local", project_dir / ".claude" / "settings.local.json"))
238
+ candidates.append(("project", project_dir / ".claude" / "settings.json"))
239
+
240
+ candidates.append(("user", home / ".claude" / "settings.json"))
241
+
242
+ for origin, settings_path in candidates:
243
+ if not settings_path.exists():
244
+ continue
245
+ try:
246
+ read_text = settings_path.read_text(encoding="utf-8")
247
+ except OSError:
248
+ continue
249
+ # Cap read size to 1 MiB to guard against DoS via giant file.
250
+ if len(read_text) > 1_048_576:
251
+ continue
252
+ try:
253
+ raw = json.loads(read_text)
254
+ except (json.JSONDecodeError, OSError):
255
+ continue
256
+ if not isinstance(raw, dict):
257
+ continue
258
+ enabled = raw.get("enabledPlugins")
259
+ if not isinstance(enabled, list):
260
+ continue
261
+ # Check whether this pack is in the list; compare by name prefix
262
+ # since installed plugins may appear as "name@marketplace-url".
263
+ for entry in enabled:
264
+ if not isinstance(entry, str):
265
+ continue
266
+ # Match by pack name as a prefix component (before '@').
267
+ entry_name = entry.split("@")[0].strip()
268
+ if entry_name == plugin_name:
269
+ return origin
270
+
271
+ return None
272
+
273
+
274
+ def _marker_scope(origin: str) -> str:
275
+ """Collapse the three-valued origin to the two-valued marker scope.
276
+
277
+ ``local`` and ``project`` → ``"repo"``
278
+ ``user`` → ``"user"``
279
+
280
+ This collapse is the Blocker-1 rail: the ``allowed-scopes`` comparison
281
+ and the marker-file location both use the two-valued scope; only the
282
+ adopter-facing stderr messages use the three-valued origin.
283
+ """
284
+ if origin in ("local", "project"):
285
+ return "repo"
286
+ return "user"
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Pack manifest helpers
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def _pack_toml(plugin_root: pathlib.Path) -> dict:
295
+ """Load and return the pack manifest dict from ``pack.toml``."""
296
+ toml_path = plugin_root / "pack.toml"
297
+ with open(toml_path, "rb") as fh:
298
+ return tomllib.load(fh)
299
+
300
+
301
+ def _manifest_hash(plugin_root: pathlib.Path) -> str:
302
+ """Return the SHA-256 hex digest of ``${CLAUDE_PLUGIN_ROOT}/pack.toml``."""
303
+ toml_path = plugin_root / "pack.toml"
304
+ data = toml_path.read_bytes()
305
+ return hashlib.sha256(data).hexdigest()
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Marker file helpers
310
+ # ---------------------------------------------------------------------------
311
+
312
+
313
+ def _marker_path(
314
+ marker_scope: str,
315
+ project_dir: pathlib.Path | None,
316
+ home: pathlib.Path,
317
+ ) -> "tuple[pathlib.Path, pathlib.Path]":
318
+ """Return ``(marker_path, resolved_jail)`` for the given scope.
319
+
320
+ ``repo`` → ``(<project_dir>/.adapt-install-marker.toml, resolved(project_dir))``
321
+ ``user`` → ``(<home>/.agentbundle/.adapt-install-marker.toml, resolved(~/.agentbundle))``
322
+
323
+ The returned ``resolved_jail`` is pre-resolved here so ``_write_marker``
324
+ can use it directly without re-resolving, closing the TOCTOU window where
325
+ a symlink introduced between probe and write would resolve both sides to
326
+ the foreign target and pass the jail check trivially (Concern-3).
327
+ """
328
+ if marker_scope == "user":
329
+ agentbundle = home / ".agentbundle"
330
+ # Symlink / non-directory probe (mirrors safety.user_state_path):
331
+ # mkdir with exist_ok=True, then check lstat. A pre-existing symlink
332
+ # (even pointing at a real directory) is refused so an attacker cannot
333
+ # redirect marker writes to an arbitrary location.
334
+ if agentbundle.is_symlink():
335
+ target = os.path.realpath(agentbundle)
336
+ raise ValueError(
337
+ f"install-marker: {agentbundle} is a symlink to {target}; refusing"
338
+ )
339
+ if agentbundle.exists() and not agentbundle.is_dir():
340
+ raise ValueError(
341
+ f"install-marker: {agentbundle} exists but is not a directory; refusing"
342
+ )
343
+ agentbundle.mkdir(mode=0o700, parents=True, exist_ok=True)
344
+ resolved_jail = pathlib.Path(os.path.realpath(agentbundle))
345
+ return agentbundle / ".adapt-install-marker.toml", resolved_jail
346
+ else:
347
+ # repo scope — project_dir must be set; callers ensure this.
348
+ if project_dir is None:
349
+ raise ValueError("project_dir required for repo-scope marker")
350
+ resolved_jail = pathlib.Path(os.path.realpath(project_dir))
351
+ return project_dir / ".adapt-install-marker.toml", resolved_jail
352
+
353
+
354
+ def _should_fire(
355
+ marker_path: pathlib.Path,
356
+ pack_name: str,
357
+ plugin_data: pathlib.Path,
358
+ current_hash: str,
359
+ ) -> bool:
360
+ """Implement the dual-detection condition.
361
+
362
+ Returns ``True`` (fire the writer) when either:
363
+ - The hash file at ``${CLAUDE_PLUGIN_DATA}/pack-manifest-hash`` is
364
+ missing or differs from ``current_hash``; OR
365
+ - The hash matches but the marker file has no ``[[packs-installed]]``
366
+ entry naming this pack.
367
+
368
+ Returns ``False`` (warm cache — skip) only when BOTH conditions hold:
369
+ the hash matches AND the marker contains an entry for this pack.
370
+ """
371
+ hash_file = plugin_data / "pack-manifest-hash"
372
+
373
+ # Condition 1: hash diff.
374
+ if not hash_file.exists():
375
+ return True
376
+ stored_hash = hash_file.read_text(encoding="utf-8").strip()
377
+ if stored_hash != current_hash:
378
+ return True
379
+
380
+ # Hash matches — check condition 2: marker entry absent.
381
+ if not marker_path.exists():
382
+ return True
383
+ try:
384
+ data = tomllib.loads(marker_path.read_text(encoding="utf-8"))
385
+ except (tomllib.TOMLDecodeError, OSError):
386
+ # Malformed marker — treat as absent entry; fire.
387
+ return True
388
+ entries = data.get("packs-installed", [])
389
+ if not isinstance(entries, list):
390
+ return True
391
+ for entry in entries:
392
+ if isinstance(entry, dict) and entry.get("name") == pack_name:
393
+ return False # warm cache: hash matches AND entry present
394
+
395
+ return True # hash matches but entry absent
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Marker write helpers
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def _read_entries(marker_path: pathlib.Path) -> list[dict]:
404
+ """Read existing ``[[packs-installed]]`` entries from the marker file.
405
+
406
+ Drops any entry whose ``installed-at`` is not a ``datetime.datetime``
407
+ (defence-in-depth: mirrors install.py:866-874).
408
+
409
+ Coerces ``unresolved-markers`` and ``new-companions`` to ``list[str]``
410
+ when they are present: non-list values or lists containing non-str items
411
+ are dropped with a one-line stderr warning, and the rest of the entry
412
+ survives (Concern-4).
413
+ """
414
+ if not marker_path.exists():
415
+ return []
416
+ try:
417
+ data = tomllib.loads(marker_path.read_text(encoding="utf-8"))
418
+ except (tomllib.TOMLDecodeError, OSError) as exc:
419
+ # Diagnostic aligned with the CLI writer at install.py:846-849.
420
+ print(
421
+ f"install-marker: warning: existing install marker at {marker_path} "
422
+ f"is malformed ({exc}); prior entries lost — re-run install "
423
+ f"for any earlier packs",
424
+ file=sys.stderr,
425
+ )
426
+ return []
427
+ raw_entries = data.get("packs-installed", [])
428
+ if not isinstance(raw_entries, list):
429
+ return []
430
+ entries: list[dict] = []
431
+ for e in raw_entries:
432
+ if not isinstance(e, dict):
433
+ continue
434
+ ts = e.get("installed-at")
435
+ if not isinstance(ts, datetime.datetime):
436
+ # Drop entries with non-datetime installed-at (same defence as CLI).
437
+ continue
438
+ # Coerce unresolved-markers and new-companions to list[str].
439
+ # A tampered marker could carry a non-list or list-with-non-str
440
+ # value for these fields; passing the raw value to _emit_basic_string
441
+ # would raise. Coerce here and warn so the entry's other valid
442
+ # fields survive re-emission.
443
+ # Security Concern 2: type-validate name, version, install-route.
444
+ # A tampered marker with name=42 (TOML integer) passes the
445
+ # installed-at filter but raises ValueError at _emit_basic_string
446
+ # time, bricking subsequent marker writes. Drop such entries.
447
+ _skip_entry = False
448
+ for _field in ("name", "version"):
449
+ _val = e.get(_field)
450
+ if _val is not None and not isinstance(_val, str):
451
+ _label = e.get("name") if _field != "name" else "<unnamed>"
452
+ _label_str = _label if isinstance(_label, str) else "<unnamed>"
453
+ print(
454
+ f"install-marker: warning: marker entry has non-string "
455
+ f"{_field} (got {type(_val).__name__}); dropping entry "
456
+ f"for pack {_label_str!r}",
457
+ file=sys.stderr,
458
+ )
459
+ _skip_entry = True
460
+ break
461
+ if not _skip_entry:
462
+ _route_val = e.get("install-route")
463
+ if _route_val is not None and not isinstance(_route_val, str):
464
+ _name_val = e.get("name", "<unnamed>")
465
+ _name_str = _name_val if isinstance(_name_val, str) else "<unnamed>"
466
+ print(
467
+ f"install-marker: warning: marker entry for {_name_str!r} "
468
+ f"has non-string install-route "
469
+ f"(got {type(_route_val).__name__}); dropping field",
470
+ file=sys.stderr,
471
+ )
472
+ e = dict(e) # shallow copy before mutation
473
+ del e["install-route"]
474
+ if _skip_entry:
475
+ continue
476
+ e = dict(e) # shallow copy so we don't mutate the tomllib-parsed dict
477
+ for field in ("unresolved-markers", "new-companions"):
478
+ if field not in e:
479
+ continue
480
+ raw_val = e[field]
481
+ if not isinstance(raw_val, list) or not all(
482
+ isinstance(item, str) for item in raw_val
483
+ ):
484
+ pack = e.get("name", "<unknown>")
485
+ actual_type = type(raw_val).__name__
486
+ print(
487
+ f"install-marker: existing marker entry for {pack} has malformed "
488
+ f"{field} ({actual_type} instead of list[str]); dropping field",
489
+ file=sys.stderr,
490
+ )
491
+ del e[field]
492
+ entries.append(e)
493
+ return entries
494
+
495
+
496
+ def _serialise_marker(entries: list[dict]) -> str:
497
+ """Serialise a list of ``[[packs-installed]]`` entries to TOML text.
498
+
499
+ Mirrors the field-for-field emission in install.py:896-934:
500
+ - ``marker-schema-version`` — basic string.
501
+ - Per entry:
502
+ - ``name`` — basic string (TOML-injection safe).
503
+ - ``version`` — basic string.
504
+ - ``installed-at`` — bare TOML offset-datetime literal (no quotes).
505
+ - ``install-route`` — basic string.
506
+ - ``unresolved-markers`` and ``new-companions`` — if present on the
507
+ entry (CLI-seeded entries carry both; writer-created entries omit
508
+ them). Pass-through verbatim so CLI-seeded entries survive re-emit.
509
+ """
510
+ lines: list[str] = [
511
+ f"marker-schema-version = {_emit_basic_string('0.1')}",
512
+ "",
513
+ ]
514
+ for entry in entries:
515
+ lines.append("[[packs-installed]]")
516
+ lines.append(f"name = {_emit_basic_string(entry['name'])}")
517
+ lines.append(f"version = {_emit_basic_string(entry['version'])}")
518
+ # Emit installed-at as a bare TOML offset-datetime literal (load-bearing).
519
+ # The CLI loader at install.py:866-874 drops any entry whose installed-at
520
+ # is not a datetime; emitting a basic-string would round-trip as str
521
+ # and get dropped on the next CLI invocation (Blocker-3 rail).
522
+ ts = entry["installed-at"]
523
+ if isinstance(ts, datetime.datetime):
524
+ ts_str = ts.strftime("%Y-%m-%dT%H:%M:%SZ")
525
+ else:
526
+ raise ValueError(
527
+ f"installed-at must be datetime, got {type(ts).__name__}"
528
+ )
529
+ lines.append(f"installed-at = {ts_str}")
530
+ # install-route: use the stored value; fall back to "cli" for entries
531
+ # written by the CLI writer before this field existed (v0.3 markers).
532
+ install_route = entry.get("install-route", "cli")
533
+ lines.append(f"install-route = {_emit_basic_string(install_route)}")
534
+ # Pass-through unresolved-markers and new-companions if present on the
535
+ # entry. CLI-seeded entries carry both; writer-created entries omit them
536
+ # (the writer has no visibility into the projected primitive tree —
537
+ # per spec). Re-emit verbatim using _emit_basic_string so CLI-seeded
538
+ # queue/companion lists survive a Claude-plugins writer pass.
539
+ if "unresolved-markers" in entry:
540
+ markers_repr = ", ".join(
541
+ _emit_basic_string(m) for m in entry.get("unresolved-markers", [])
542
+ )
543
+ lines.append(f"unresolved-markers = [{markers_repr}]")
544
+ if "new-companions" in entry:
545
+ comps_repr = ", ".join(
546
+ _emit_basic_string(c) for c in entry.get("new-companions", [])
547
+ )
548
+ lines.append(f"new-companions = [{comps_repr}]")
549
+ lines.append("")
550
+ return "\n".join(lines).rstrip() + "\n"
551
+
552
+
553
+ def _write_marker(
554
+ marker_path: pathlib.Path,
555
+ new_entry: dict,
556
+ jail: pathlib.Path,
557
+ ) -> None:
558
+ """Read-modify-write the marker file via atomic rename.
559
+
560
+ Reads existing entries, replaces any existing entry for the same pack name
561
+ (upgrade semantics — does not stack), appends the new entry if not
562
+ replacing, then writes via ``tempfile.NamedTemporaryFile`` + ``os.replace``.
563
+
564
+ The tempfile is created in the same directory as the marker file so
565
+ ``os.replace`` is always on the same filesystem (the POSIX guarantee
566
+ that makes it atomic).
567
+
568
+ ``jail`` is the **pre-resolved** per-scope root (as returned by
569
+ ``_marker_path``): the real path of ``~/.agentbundle`` for user scope,
570
+ or the real path of ``project_dir`` for repo scope. Callers must pass
571
+ the value that ``_marker_path`` returns without re-resolving — this closes
572
+ the TOCTOU window where a symlink introduced between probe and write would
573
+ otherwise redirect the output. The path-jail check runs before the write.
574
+ """
575
+ # Path-jail check: verify marker_path resolves inside jail.
576
+ # jail is a pre-resolved path from _marker_path; _assert_under does not
577
+ # re-resolve it so the probe-time trusted jail value is used throughout.
578
+ _assert_under(marker_path, jail)
579
+ # Portable-name check on filename components *under* the jail only.
580
+ # The jail trusted-prefix (e.g. "/home/user/.agentbundle") is not
581
+ # user-influenced and contains OS-specific separators (e.g. "C:\\")
582
+ # on Windows that would falsely trigger the "forbidden character ':'"
583
+ # guard. Only the components beneath the jail need validation.
584
+ try:
585
+ rel = marker_path.relative_to(jail)
586
+ except ValueError:
587
+ # marker_path is outside jail; _assert_under already raised,
588
+ # but guard defensively in case the call order is rearranged.
589
+ rel = marker_path
590
+ for part in rel.parts:
591
+ _assert_portable_name(part)
592
+
593
+ existing = _read_entries(marker_path)
594
+ # Replace any existing entry for the same pack name (AC8 upgrade semantics).
595
+ entries = [e for e in existing if e.get("name") != new_entry["name"]]
596
+ entries.append(new_entry)
597
+
598
+ content = _serialise_marker(entries)
599
+ content_bytes = content.encode("utf-8")
600
+
601
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
602
+
603
+ # Write to a tempfile in the same directory, then os.replace for atomicity.
604
+ tmp_fd, tmp_name = tempfile.mkstemp(dir=marker_path.parent, suffix=".tmp")
605
+ try:
606
+ os.write(tmp_fd, content_bytes)
607
+ os.close(tmp_fd)
608
+ tmp_fd = -1
609
+ os.replace(tmp_name, marker_path)
610
+ except Exception:
611
+ # Clean up tempfile on error (best effort).
612
+ if tmp_fd >= 0:
613
+ try:
614
+ os.close(tmp_fd)
615
+ except OSError:
616
+ pass
617
+ try:
618
+ os.unlink(tmp_name)
619
+ except OSError:
620
+ pass
621
+ raise
622
+
623
+
624
+ def _write_hash(plugin_data: pathlib.Path, current_hash: str) -> None:
625
+ """Write the hash file at ``${CLAUDE_PLUGIN_DATA}/pack-manifest-hash``.
626
+
627
+ Only called from ``main`` AFTER ``_write_marker`` returns successfully
628
+ (the write-after-success ordering is the spec's robustness rail — see
629
+ Boundaries §Never do). Hash-write failure is non-fatal (main allows it
630
+ to proceed with exit 0 + warning); the next session retries detection.
631
+
632
+ Uses tempfile + os.replace for atomicity, matching the marker write rail.
633
+ """
634
+ plugin_data.mkdir(parents=True, exist_ok=True)
635
+ hash_path = plugin_data / "pack-manifest-hash"
636
+ content_bytes = (current_hash + "\n").encode("utf-8")
637
+ # Atomic write — tempfile-in-parent + os.replace, matching the marker rail.
638
+ tmp_fd, tmp_name = tempfile.mkstemp(dir=plugin_data, suffix=".tmp")
639
+ try:
640
+ os.write(tmp_fd, content_bytes)
641
+ os.close(tmp_fd)
642
+ tmp_fd = -1
643
+ os.replace(tmp_name, hash_path)
644
+ except Exception:
645
+ if tmp_fd >= 0:
646
+ try:
647
+ os.close(tmp_fd)
648
+ except OSError:
649
+ pass
650
+ try:
651
+ os.unlink(tmp_name)
652
+ except OSError:
653
+ pass
654
+ raise
655
+
656
+
657
+ # ---------------------------------------------------------------------------
658
+ # APM-route helpers (apm-install-route-parity AC3 / AC4)
659
+ # ---------------------------------------------------------------------------
660
+
661
+
662
+ def _resolve_data_dir(env: "dict[str, str]") -> "pathlib.Path | None":
663
+ """Resolve hash-file directory per the apm-install-route-parity AC3 precedence.
664
+
665
+ Precedence (first set-and-non-empty wins):
666
+ 1. ``${CLAUDE_PLUGIN_DATA}`` — APM at Claude Code target.
667
+ 2. ``${PLUGIN_ROOT}/.data`` — APM's generic per-target token.
668
+ 3. ``${CURSOR_PLUGIN_ROOT}/.data`` — APM at Cursor target.
669
+
670
+ Returns ``None`` when none of the three is set-and-non-empty; callers
671
+ treat this as the no-match fall-through and exit 0 without writing the
672
+ marker (the no-partial-state rail).
673
+
674
+ Empty-string values are treated as unset (an APM target that exports
675
+ ``PLUGIN_ROOT=""`` must not be silently picked over a later-precedence
676
+ fallback that *is* set).
677
+ """
678
+ cpd = env.get("CLAUDE_PLUGIN_DATA", "")
679
+ if cpd:
680
+ return pathlib.Path(cpd)
681
+ pr = env.get("PLUGIN_ROOT", "")
682
+ if pr:
683
+ return pathlib.Path(pr) / ".data"
684
+ cpr = env.get("CURSOR_PLUGIN_ROOT", "")
685
+ if cpr:
686
+ return pathlib.Path(cpr) / ".data"
687
+ return None
688
+
689
+
690
+ def _apm_detect_scope(
691
+ writer_path: pathlib.Path,
692
+ cwd: pathlib.Path,
693
+ home: pathlib.Path,
694
+ ) -> "str | None":
695
+ """Detect APM-route marker scope by writer's resolved ``__file__`` containment.
696
+
697
+ Returns:
698
+ ``"repo"`` if ``writer_path.resolve()`` is contained under
699
+ ``cwd.resolve()`` — first-branch-wins, even when ``cwd`` is itself
700
+ nested under ``$HOME`` and the home branch would also succeed in the
701
+ abstract (AC4 case (a)).
702
+ ``"user"`` if (and only if) the repo branch fails and ``writer_path``
703
+ is contained under ``home.resolve()``.
704
+ ``None`` otherwise — no-match fall-through; the caller exits 0 without
705
+ writing the marker (the no-partial-state rail).
706
+
707
+ Symlinks are resolved on both sides via ``.resolve()`` before comparison
708
+ so a writer that lives under a symlinked cache directory still passes
709
+ the containment check (AC4 case (d)).
710
+ """
711
+ wp = writer_path.resolve()
712
+ cwd_r = cwd.resolve()
713
+ home_r = home.resolve()
714
+ try:
715
+ if wp.is_relative_to(cwd_r):
716
+ return "repo"
717
+ except ValueError:
718
+ pass
719
+ try:
720
+ if wp.is_relative_to(home_r):
721
+ return "user"
722
+ except ValueError:
723
+ pass
724
+ return None
725
+
726
+
727
+ # ---------------------------------------------------------------------------
728
+ # Main entrypoint
729
+ # ---------------------------------------------------------------------------
730
+
731
+
732
+ def _parse_args(argv: "list[str]") -> argparse.Namespace:
733
+ """Parse the writer's CLI surface.
734
+
735
+ ``--install-route`` is two-valued (``claude-plugins`` | ``apm``) and
736
+ ``required=True`` — argparse exits non-zero with a usage message on
737
+ either missing flag or invalid choice. ``"cli"`` is *not* admitted; the
738
+ CLI route uses ``agentbundle install._append_install_marker`` directly
739
+ and never invokes this template.
740
+ """
741
+ parser = argparse.ArgumentParser(prog="install-marker")
742
+ parser.add_argument(
743
+ "--install-route",
744
+ choices=["claude-plugins", "apm"],
745
+ required=True,
746
+ help=(
747
+ "the install route that projected this writer; baked into the "
748
+ "[[packs-installed]] entry verbatim. Required; no default."
749
+ ),
750
+ )
751
+ return parser.parse_args(argv)
752
+
753
+
754
+ def main(argv: "list[str]") -> int:
755
+ """Writer entrypoint.
756
+
757
+ Parses ``--install-route``, dispatches to the route-appropriate scope-
758
+ detection path, and writes the marker. Returns exit code.
759
+ """
760
+ args = _parse_args(argv)
761
+
762
+ if args.install_route == "apm":
763
+ return _main_apm(args)
764
+ return _main_claude_plugins(args)
765
+
766
+
767
+ def _main_claude_plugins(args: argparse.Namespace) -> int:
768
+ """Claude-plugins-route writer (behaviour-preserving past the argparse prefix).
769
+
770
+ Reads ``${CLAUDE_PLUGIN_ROOT}`` and ``${CLAUDE_PLUGIN_DATA}`` from
771
+ ``os.environ``; scope detection via ``_detect_origin`` (enabledPlugins walk).
772
+ """
773
+ # --- Environment ---
774
+ plugin_root_str = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
775
+ plugin_data_str = os.environ.get("CLAUDE_PLUGIN_DATA", "")
776
+ home_str = os.environ.get("HOME", "")
777
+ project_dir_str = os.environ.get("CLAUDE_PROJECT_DIR", "")
778
+
779
+ if not plugin_root_str:
780
+ print("install-marker: CLAUDE_PLUGIN_ROOT is not set", file=sys.stderr)
781
+ return 1
782
+ if not plugin_data_str:
783
+ print("install-marker: CLAUDE_PLUGIN_DATA is not set", file=sys.stderr)
784
+ return 1
785
+ if not home_str:
786
+ print("install-marker: HOME is not set", file=sys.stderr)
787
+ return 1
788
+
789
+ plugin_root = pathlib.Path(plugin_root_str)
790
+ plugin_data = pathlib.Path(plugin_data_str)
791
+ home = pathlib.Path(home_str)
792
+ project_dir: pathlib.Path | None = (
793
+ pathlib.Path(project_dir_str) if project_dir_str else None
794
+ )
795
+
796
+ # --- Load pack manifest ---
797
+ try:
798
+ pack = _pack_toml(plugin_root)
799
+ except Exception as exc:
800
+ print(f"install-marker: failed to read pack.toml: {exc}", file=sys.stderr)
801
+ return 1
802
+
803
+ pack_meta = pack.get("pack", {})
804
+ pack_name: str = pack_meta.get("name", "")
805
+ pack_version: str = pack_meta.get("version", "")
806
+ install_table = pack_meta.get("install", {})
807
+ allowed_scopes: list = install_table.get("allowed-scopes", [])
808
+
809
+ if not pack_name:
810
+ print("install-marker: pack.toml is missing [pack].name", file=sys.stderr)
811
+ return 1
812
+
813
+ # Security Concern 7: validate pack name and version against the same
814
+ # shape rules the CLI enforces (_PACK_NAME_RE / _PACK_VERSION_RE vendored
815
+ # above from packages/agentbundle/agentbundle/commands/install.py).
816
+ # A pack with control chars / newlines in name passes undetected otherwise,
817
+ # enabling TOML injection in the marker file. Refuse-and-warn, exit 0 (no
818
+ # marker write, no hash file update) so the next session retries.
819
+ if not isinstance(pack_name, str) or not _PACK_NAME_RE.fullmatch(pack_name):
820
+ print(
821
+ f"install-marker: pack name {pack_name!r} fails pack-name shape rule "
822
+ f"(must match ^[a-z0-9][a-z0-9-]*$); skipping marker write",
823
+ file=sys.stderr,
824
+ )
825
+ return 0
826
+
827
+ if pack_version and (
828
+ not isinstance(pack_version, str)
829
+ or not _PACK_VERSION_RE.fullmatch(pack_version)
830
+ ):
831
+ print(
832
+ f"install-marker: pack version for {pack_name!r} fails pack-version "
833
+ f"shape rule; skipping marker write",
834
+ file=sys.stderr,
835
+ )
836
+ return 0
837
+
838
+ # --- Compute hash ---
839
+ try:
840
+ current_hash = _manifest_hash(plugin_root)
841
+ except Exception as exc:
842
+ print(f"install-marker: failed to hash pack.toml: {exc}", file=sys.stderr)
843
+ return 1
844
+
845
+ # --- Scope detection (claude-plugins route: enabledPlugins walk) ---
846
+ origin = _detect_origin(
847
+ plugin_name=pack_name,
848
+ home=home,
849
+ project_dir=project_dir,
850
+ )
851
+
852
+ if origin is None:
853
+ # No-match fall-through: exit 0 without writing the marker AND
854
+ # without updating ${CLAUDE_PLUGIN_DATA} so the next session retries.
855
+ return 0
856
+
857
+ scope = _marker_scope(origin)
858
+
859
+ # --- Allowed-scopes refusal rail ---
860
+ if allowed_scopes and scope not in allowed_scopes:
861
+ print(
862
+ f"install-marker: pack {pack_name} declares allowed-scopes={allowed_scopes!r}, "
863
+ f"detected install scope {origin}; skipping marker write",
864
+ file=sys.stderr,
865
+ )
866
+ # Exit 0; do NOT update ${CLAUDE_PLUGIN_DATA} so next session re-checks.
867
+ return 0
868
+
869
+ # --- Derive marker path ---
870
+ if scope == "repo" and project_dir is None:
871
+ # Cannot write a repo-scope marker without a project directory.
872
+ # This can happen if CLAUDE_PROJECT_DIR is unset but scope is repo
873
+ # (should not occur given detection logic, but guard defensively).
874
+ print(
875
+ "install-marker: repo-scope marker requested but CLAUDE_PROJECT_DIR is unset",
876
+ file=sys.stderr,
877
+ )
878
+ return 0
879
+
880
+ try:
881
+ marker, resolved_jail = _marker_path(scope, project_dir, home)
882
+ except Exception as exc:
883
+ print(f"install-marker: failed to resolve marker path: {exc}", file=sys.stderr)
884
+ return 1
885
+
886
+ # --- Dual-detection check ---
887
+ if not _should_fire(marker, pack_name, plugin_data, current_hash):
888
+ # Warm cache — exit cleanly, nothing to do.
889
+ return 0
890
+
891
+ # --- Build new entry ---
892
+ new_entry: dict = {
893
+ "name": pack_name,
894
+ "version": pack_version,
895
+ "installed-at": datetime.datetime.now(timezone.utc),
896
+ "install-route": args.install_route,
897
+ }
898
+
899
+ # --- Write marker (must succeed before writing hash file) ---
900
+ # resolved_jail is pre-resolved by _marker_path; passing it directly
901
+ # to _write_marker closes the TOCTOU window (Concern-3).
902
+ try:
903
+ _write_marker(marker, new_entry, resolved_jail)
904
+ except Exception as exc:
905
+ print(f"install-marker: marker write failed: {exc}", file=sys.stderr)
906
+ # Do NOT write hash file — next session retries the marker write.
907
+ return 1
908
+
909
+ # --- Write hash file only after marker write succeeds ---
910
+ try:
911
+ _write_hash(plugin_data, current_hash)
912
+ except Exception as exc:
913
+ # Hash write failure is non-fatal for the adopter (the marker was written),
914
+ # but log it so the next session retries detection rather than silently skipping.
915
+ print(f"install-marker: hash write failed (next session will retry): {exc}", file=sys.stderr)
916
+
917
+ return 0
918
+
919
+
920
+ def _main_apm(args: argparse.Namespace) -> int:
921
+ """APM-route writer (apm-install-route-parity AC2/3/4/5).
922
+
923
+ Reads precedence-resolved data directory and pack root from the APM
924
+ environment; scope detection by writer's own resolved ``__file__`` path
925
+ containment under ``cwd`` (→ repo) or ``$HOME`` (→ user). The marker
926
+ schema and the allowed-scopes refusal rail are unchanged from the
927
+ claude-plugins route — only the *detection* mechanism differs.
928
+ """
929
+ # --- Environment ---
930
+ env = os.environ
931
+ home_str = env.get("HOME", "")
932
+ if not home_str:
933
+ print("install-marker: HOME is not set", file=sys.stderr)
934
+ return 1
935
+ home = pathlib.Path(home_str)
936
+
937
+ # --- Resolve data directory per AC3 precedence ---
938
+ plugin_data = _resolve_data_dir(env)
939
+ if plugin_data is None:
940
+ # No-match fall-through (no APM data-directory token set); exit 0
941
+ # without writing marker or hash file (the no-partial-state rail).
942
+ # Emit a one-line stderr so an APM target whose token names we got
943
+ # wrong does not silently no-op forever — the writer's failure
944
+ # mode appears in the target tool's hook log.
945
+ print(
946
+ "install-marker: no APM data-directory token set "
947
+ "(looked for ${CLAUDE_PLUGIN_DATA}, ${PLUGIN_ROOT}, "
948
+ "${CURSOR_PLUGIN_ROOT}); skipping marker write",
949
+ file=sys.stderr,
950
+ )
951
+ return 0
952
+
953
+ # --- Resolve pack root ---
954
+ # Pack-root precedence mirrors the data-dir chain's structure
955
+ # (Claude Code → generic → Cursor) but uses the pack-root token
956
+ # at each tier (CLAUDE_PLUGIN_ROOT / PLUGIN_ROOT / CURSOR_PLUGIN_ROOT);
957
+ # data-dir's first tier is CLAUDE_PLUGIN_DATA, not CLAUDE_PLUGIN_ROOT,
958
+ # so the two token sets overlap but are not identical.
959
+ cpr_pack = env.get("CLAUDE_PLUGIN_ROOT", "")
960
+ pr_pack = env.get("PLUGIN_ROOT", "")
961
+ cur_pack = env.get("CURSOR_PLUGIN_ROOT", "")
962
+ if cpr_pack:
963
+ plugin_root = pathlib.Path(cpr_pack)
964
+ elif pr_pack:
965
+ plugin_root = pathlib.Path(pr_pack)
966
+ elif cur_pack:
967
+ plugin_root = pathlib.Path(cur_pack)
968
+ else:
969
+ # Data dir resolved (per above) but no pack-root token set: same
970
+ # no-partial-state rail — exit 0 without writing.
971
+ print(
972
+ "install-marker: no APM pack-root token set "
973
+ "(looked for ${CLAUDE_PLUGIN_ROOT}, ${PLUGIN_ROOT}, "
974
+ "${CURSOR_PLUGIN_ROOT}); skipping marker write",
975
+ file=sys.stderr,
976
+ )
977
+ return 0
978
+
979
+ # --- Load pack manifest ---
980
+ try:
981
+ pack = _pack_toml(plugin_root)
982
+ except Exception as exc:
983
+ print(f"install-marker: failed to read pack.toml: {exc}", file=sys.stderr)
984
+ return 1
985
+
986
+ pack_meta = pack.get("pack", {})
987
+ pack_name: str = pack_meta.get("name", "")
988
+ pack_version: str = pack_meta.get("version", "")
989
+ install_table = pack_meta.get("install", {})
990
+ allowed_scopes: list = install_table.get("allowed-scopes", [])
991
+
992
+ if not pack_name:
993
+ print("install-marker: pack.toml is missing [pack].name", file=sys.stderr)
994
+ return 1
995
+
996
+ # Vendored pack-name / pack-version shape rules — identical to the
997
+ # claude-plugins branch above (security-load-bearing; do not skip).
998
+ if not isinstance(pack_name, str) or not _PACK_NAME_RE.fullmatch(pack_name):
999
+ print(
1000
+ f"install-marker: pack name {pack_name!r} fails pack-name shape rule "
1001
+ f"(must match ^[a-z0-9][a-z0-9-]*$); skipping marker write",
1002
+ file=sys.stderr,
1003
+ )
1004
+ return 0
1005
+ if pack_version and (
1006
+ not isinstance(pack_version, str)
1007
+ or not _PACK_VERSION_RE.fullmatch(pack_version)
1008
+ ):
1009
+ print(
1010
+ f"install-marker: pack version for {pack_name!r} fails pack-version "
1011
+ f"shape rule; skipping marker write",
1012
+ file=sys.stderr,
1013
+ )
1014
+ return 0
1015
+
1016
+ # --- Compute hash ---
1017
+ try:
1018
+ current_hash = _manifest_hash(plugin_root)
1019
+ except Exception as exc:
1020
+ print(f"install-marker: failed to hash pack.toml: {exc}", file=sys.stderr)
1021
+ return 1
1022
+
1023
+ # --- Scope detection (APM route: writer's projected-path containment) ---
1024
+ writer_path = pathlib.Path(__file__)
1025
+ cwd = pathlib.Path.cwd()
1026
+ scope = _apm_detect_scope(writer_path, cwd, home)
1027
+ if scope is None:
1028
+ # No-match fall-through: writer not contained under cwd or $HOME.
1029
+ # Emit a one-line stderr so the failure mode is visible — a writer
1030
+ # projected outside both roots is most often a target-tool packaging
1031
+ # bug (cache directory under a non-standard mount point).
1032
+ print(
1033
+ f"install-marker: writer at {writer_path} not under cwd "
1034
+ f"({cwd}) or $HOME ({home}); skipping marker write",
1035
+ file=sys.stderr,
1036
+ )
1037
+ return 0
1038
+
1039
+ # --- Allowed-scopes refusal rail (unchanged grammar from claude-plugins) ---
1040
+ if allowed_scopes and scope not in allowed_scopes:
1041
+ print(
1042
+ f"install-marker: pack {pack_name} declares allowed-scopes={allowed_scopes!r}, "
1043
+ f"detected install scope {scope}; skipping marker write",
1044
+ file=sys.stderr,
1045
+ )
1046
+ return 0
1047
+
1048
+ # --- Derive marker path ---
1049
+ # For APM repo scope, cwd plays the role project_dir plays in the
1050
+ # claude-plugins route — the marker lands under it directly.
1051
+ project_dir_for_marker = cwd if scope == "repo" else None
1052
+ try:
1053
+ marker, resolved_jail = _marker_path(scope, project_dir_for_marker, home)
1054
+ except Exception as exc:
1055
+ print(f"install-marker: failed to resolve marker path: {exc}", file=sys.stderr)
1056
+ return 1
1057
+
1058
+ # --- Ensure data directory exists ---
1059
+ # APM does not pre-create the per-pack .data/ subdirectory; mkdir before
1060
+ # the hash file write rail attempts to open it.
1061
+ try:
1062
+ plugin_data.mkdir(parents=True, exist_ok=True)
1063
+ except Exception as exc:
1064
+ print(f"install-marker: failed to create data directory: {exc}", file=sys.stderr)
1065
+ return 1
1066
+
1067
+ # --- Dual-detection check ---
1068
+ if not _should_fire(marker, pack_name, plugin_data, current_hash):
1069
+ return 0
1070
+
1071
+ # --- Build new entry ---
1072
+ new_entry: dict = {
1073
+ "name": pack_name,
1074
+ "version": pack_version,
1075
+ "installed-at": datetime.datetime.now(timezone.utc),
1076
+ "install-route": args.install_route,
1077
+ }
1078
+
1079
+ # --- Write marker (must succeed before writing hash file) ---
1080
+ try:
1081
+ _write_marker(marker, new_entry, resolved_jail)
1082
+ except Exception as exc:
1083
+ print(f"install-marker: marker write failed: {exc}", file=sys.stderr)
1084
+ return 1
1085
+
1086
+ # --- Write hash file only after marker write succeeds ---
1087
+ try:
1088
+ _write_hash(plugin_data, current_hash)
1089
+ except Exception as exc:
1090
+ print(
1091
+ f"install-marker: hash write failed (next session will retry): {exc}",
1092
+ file=sys.stderr,
1093
+ )
1094
+
1095
+ return 0
1096
+
1097
+
1098
+ if __name__ == "__main__":
1099
+ sys.exit(main(sys.argv[1:]))