agentbundle 0.11.1__tar.gz → 0.12.1__tar.gz

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 (130) hide show
  1. {agentbundle-0.11.1 → agentbundle-0.12.1}/PKG-INFO +2 -2
  2. {agentbundle-0.11.1 → agentbundle-0.12.1}/README.md +1 -1
  3. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/_data/pack.schema.json +20 -0
  4. agentbundle-0.12.1/agentbundle/_data/plugin-manifest.derived.schema.json +73 -0
  5. agentbundle-0.11.1/agentbundle/_data/plugin-manifest.derived.schema.json → agentbundle-0.12.1/agentbundle/_data/plugin-manifest.schema.json +14 -12
  6. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/claude_code.py +15 -4
  7. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/codex.py +16 -5
  8. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/copilot.py +19 -8
  9. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/cursor.py +2 -2
  10. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/kiro.py +14 -1
  11. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/main.py +100 -8
  12. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/gemini_command_toml.py +2 -2
  13. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/self_host.py +18 -12
  14. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_plugin_manifest_schema.py +30 -4
  15. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_projectable_subset.py +18 -4
  16. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_self_host_check.py +39 -60
  17. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_shipped_packs_v08_declarations.py +2 -0
  18. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/_common.py +49 -33
  19. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/diff.py +3 -3
  20. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/install.py +58 -24
  21. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/uninstall.py +3 -2
  22. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/upgrade.py +25 -21
  23. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/safety.py +38 -22
  24. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/version.py +1 -1
  25. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle.egg-info/PKG-INFO +2 -2
  26. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle.egg-info/SOURCES.txt +2 -1
  27. {agentbundle-0.11.1 → agentbundle-0.12.1}/pyproject.toml +1 -1
  28. agentbundle-0.12.1/tests/test_linear_primitive.py +133 -0
  29. agentbundle-0.11.1/agentbundle/_data/plugin-manifest.schema.json +0 -28
  30. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/__init__.py +0 -0
  31. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/__main__.py +0 -0
  32. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/_data/adapter.schema.json +0 -0
  33. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/_data/adapter.toml +0 -0
  34. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/_data/install-defaults.toml +0 -0
  35. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/_data/install-marker.py +0 -0
  36. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/_data/profile.schema.json +0 -0
  37. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/__init__.py +0 -0
  38. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/__main__.py +0 -0
  39. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapter_root_bins.py +0 -0
  40. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/__init__.py +0 -0
  41. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/gemini.py +0 -0
  42. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/kiro_cli.py +0 -0
  43. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/adapters/kiro_ide.py +0 -0
  44. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/contract.py +0 -0
  45. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/lint_packs.py +0 -0
  46. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/phase_order.py +0 -0
  47. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/__init__.py +0 -0
  48. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/codex_agent_toml.py +0 -0
  49. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/copilot_agent_md.py +0 -0
  50. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/copilot_hooks_json.py +0 -0
  51. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/direct_directory.py +0 -0
  52. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/hook_id.py +0 -0
  53. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/kiro_ide_hook.py +0 -0
  54. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/merge_into_agent_json.py +0 -0
  55. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/merge_json.py +0 -0
  56. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/projections/user_merge_json.py +0 -0
  57. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/scope_rails.py +0 -0
  58. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/shared_libs.py +0 -0
  59. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/target_resolver.py +0 -0
  60. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/__init__.py +0 -0
  61. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_claude_code.py +0 -0
  62. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_codex.py +0 -0
  63. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_copilot.py +0 -0
  64. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_cursor.py +0 -0
  65. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_gemini.py +0 -0
  66. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_kiro.py +0 -0
  67. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_kiro_alias.py +0 -0
  68. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_kiro_cli.py +0 -0
  69. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_kiro_ide.py +0 -0
  70. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_adapter_root_bins_projection.py +0 -0
  71. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_architect_design_reviewer_projection.py +0 -0
  72. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_architect_design_reviewer_rubric_parity.py +0 -0
  73. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_build_ships_seeds.py +0 -0
  74. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_contract.py +0 -0
  75. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_contract_scope.py +0 -0
  76. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_contract_v07.py +0 -0
  77. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_contract_v08.py +0 -0
  78. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_direct_directory_cleanup.py +0 -0
  79. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_end_to_end_build.py +0 -0
  80. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_lint_agents_md_diataxis_block.py +0 -0
  81. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_lint_agents_md_legacy_block.py +0 -0
  82. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_lint_agents_md_risk_block.py +0 -0
  83. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_lint_packs.py +0 -0
  84. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +0 -0
  85. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_pack_schema.py +0 -0
  86. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_pack_schema_allowed_adapters.py +0 -0
  87. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_pack_schema_install.py +0 -0
  88. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_pipeline.py +0 -0
  89. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_projections_merge_json.py +0 -0
  90. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_scope_rails.py +0 -0
  91. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_security.py +0 -0
  92. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_self_host_fixture_guard.py +0 -0
  93. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_self_host_recipe_config.py +0 -0
  94. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_shared_libs_projection.py +0 -0
  95. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_shared_prefix_contract.py +0 -0
  96. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_shipped_packs_v07_declarations.py +0 -0
  97. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_user_libs_projection.py +0 -0
  98. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_validate.py +0 -0
  99. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/tests/test_writers_emit_lf.py +0 -0
  100. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/user_libs.py +0 -0
  101. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/build/validate.py +0 -0
  102. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/catalogue.py +0 -0
  103. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/categories.py +0 -0
  104. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/cli.py +0 -0
  105. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/__init__.py +0 -0
  106. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/_drop_warning.py +0 -0
  107. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/adapt.py +0 -0
  108. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/config.py +0 -0
  109. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/init_state.py +0 -0
  110. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/list_installed.py +0 -0
  111. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/list_packs.py +0 -0
  112. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/list_profiles.py +0 -0
  113. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/list_targets.py +0 -0
  114. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/profile.py +0 -0
  115. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/reconcile.py +0 -0
  116. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/render.py +0 -0
  117. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/scaffold.py +0 -0
  118. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/show.py +0 -0
  119. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/commands/validate.py +0 -0
  120. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/config.py +0 -0
  121. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/pack_inventory.py +0 -0
  122. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/render.py +0 -0
  123. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/scope.py +0 -0
  124. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/source_defaults.py +0 -0
  125. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/statelock.py +0 -0
  126. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle/user_config.py +0 -0
  127. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle.egg-info/dependency_links.txt +0 -0
  128. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle.egg-info/entry_points.txt +0 -0
  129. {agentbundle-0.11.1 → agentbundle-0.12.1}/agentbundle.egg-info/top_level.txt +0 -0
  130. {agentbundle-0.11.1 → agentbundle-0.12.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentbundle
3
- Version: 0.11.1
3
+ Version: 0.12.1
4
4
  Summary: npm for your coding agent. Install packs of skills, subagents, and hooks into any repo, for every major agent.
5
5
  Author-email: eugenelim <eugenelim@users.noreply.github.com>
6
6
  License: Apache-2.0 OR MIT
@@ -41,7 +41,7 @@ No catalogue argument needed: it defaults to the agent-ready-repo catalogue. It
41
41
  **Install for yourself, everywhere** — so a pack follows you across every project, with no per-repo setup:
42
42
 
43
43
  ```bash
44
- agentbundle install --pack research --scope user
44
+ agentbundle install --pack desk-research --scope user
45
45
  ```
46
46
 
47
47
  User-scope packs land in your home directory, not the repo — they're yours, not the team's, and they're there in every project you open.
@@ -23,7 +23,7 @@ No catalogue argument needed: it defaults to the agent-ready-repo catalogue. It
23
23
  **Install for yourself, everywhere** — so a pack follows you across every project, with no per-repo setup:
24
24
 
25
25
  ```bash
26
- agentbundle install --pack research --scope user
26
+ agentbundle install --pack desk-research --scope user
27
27
  ```
28
28
 
29
29
  User-scope packs land in your home directory, not the repo — they're yours, not the team's, and they're there in every project you open.
@@ -150,6 +150,26 @@
150
150
  }
151
151
  }
152
152
  }
153
+ },
154
+ "first-value": {
155
+ "type": "object",
156
+ "description": "Pack first-value contract (RFC-0064 Amendment #4). Level A required for all packs; Level B required when level-b = true.",
157
+ "properties": {
158
+ "audience-posture": {"type": "string", "enum": ["non-technical", "mixed", "technical"]},
159
+ "surfaces": {"type": "array", "items": {"type": "string"}},
160
+ "prerequisites": {"type": "array", "items": {"type": "string"}},
161
+ "verification": {"type": "string"},
162
+ "recovery": {"type": "string"},
163
+ "level-b": {"type": "boolean"},
164
+ "starter-task": {"type": "string"},
165
+ "starter-prompt": {"type": "string"},
166
+ "expected-result": {"type": "string"},
167
+ "next-action": {"type": "string"},
168
+ "tutorial": {"type": "string"},
169
+ "writes-to-repo": {"type": "boolean"},
170
+ "safety-gate": {"type": "string"}
171
+ },
172
+ "additionalProperties": false
153
173
  }
154
174
  },
155
175
  "if": {
@@ -0,0 +1,73 @@
1
+ {
2
+ "type": "object",
3
+ "required": ["name", "version", "description"],
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "name": {"type": "string"},
7
+ "version": {"type": "string"},
8
+ "description": {"type": "string"},
9
+ "skills": {
10
+ "type": "array",
11
+ "items": {"type": "string"}
12
+ },
13
+ "agents": {
14
+ "type": "array",
15
+ "items": {"type": "string"}
16
+ },
17
+ "author": {
18
+ "type": "object",
19
+ "required": ["name"],
20
+ "properties": {
21
+ "name": {"type": "string"},
22
+ "email": {"type": "string"}
23
+ }
24
+ },
25
+ "license": {"type": "string"},
26
+ "homepage": {"type": "string"},
27
+ "repository": {"type": "string"},
28
+ "keywords": {
29
+ "type": "array",
30
+ "items": {"type": "string"}
31
+ },
32
+ "displayName": {"type": "string"},
33
+ "source": {
34
+ "type": "object",
35
+ "required": ["source", "repo", "branch", "directory"],
36
+ "additionalProperties": false,
37
+ "properties": {
38
+ "source": {"type": "string", "enum": ["github"]},
39
+ "repo": {"type": "string"},
40
+ "branch": {"type": "string"},
41
+ "directory": {"type": "string"}
42
+ }
43
+ },
44
+ "hooks": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "properties": {
48
+ "SessionStart": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "properties": {
54
+ "hooks": {
55
+ "type": "array",
56
+ "items": {
57
+ "type": "object",
58
+ "required": ["type", "command"],
59
+ "additionalProperties": false,
60
+ "properties": {
61
+ "type": {"type": "string", "enum": ["command"]},
62
+ "command": {"type": "string"},
63
+ "timeout": {"type": "integer"}
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
@@ -14,7 +14,14 @@
14
14
  "type": "array",
15
15
  "items": {"type": "string"}
16
16
  },
17
- "author": {"type": "string"},
17
+ "author": {
18
+ "type": "object",
19
+ "required": ["name"],
20
+ "properties": {
21
+ "name": {"type": "string"},
22
+ "email": {"type": "string"}
23
+ }
24
+ },
18
25
  "license": {"type": "string"},
19
26
  "homepage": {"type": "string"},
20
27
  "repository": {"type": "string"},
@@ -24,19 +31,14 @@
24
31
  },
25
32
  "category": {"type": "string"},
26
33
  "displayName": {"type": "string"},
27
- "hooks": {
34
+ "source": {
28
35
  "type": "object",
36
+ "additionalProperties": false,
29
37
  "properties": {
30
- "SessionStart": {
31
- "type": "array",
32
- "items": {
33
- "type": "object",
34
- "required": ["command"],
35
- "properties": {
36
- "command": {"type": "string"}
37
- }
38
- }
39
- }
38
+ "source": {"type": "string"},
39
+ "repo": {"type": "string"},
40
+ "branch": {"type": "string"},
41
+ "directory": {"type": "string"}
40
42
  }
41
43
  }
42
44
  }
@@ -107,6 +107,17 @@ def _project_single(pack_path: Path, contract: dict, output_root: Path) -> None:
107
107
  raise ValueError(f"claude-code: unhandled mode {mode!r} for {primitive_name}")
108
108
 
109
109
 
110
+ def _ignore_symlinks(directory: str, names: list[str]) -> set[str]:
111
+ """`shutil.copytree` ignore callback: skip every symlink member.
112
+
113
+ Drops nested symlinks so they are never reproduced in the output
114
+ tree. The top-level `is_symlink()` skip in `_project_direct_directory`
115
+ covers the skill root; this covers the subtree.
116
+ """
117
+ base = Path(directory)
118
+ return {name for name in names if (base / name).is_symlink()}
119
+
120
+
110
121
  def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
111
122
  for entry in sorted(source_dir.iterdir()):
112
123
  # Defense-in-depth — `lint-packs` rejects packs that ship
@@ -125,10 +136,10 @@ def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
125
136
  destination.unlink()
126
137
  elif destination.exists():
127
138
  shutil.rmtree(destination)
128
- # symlinks=True preserves symlinks rather than dereferencing
129
- # them a malicious pack with a symlink to /etc/passwd
130
- # cannot exfiltrate the target into the projection.
131
- shutil.copytree(entry, destination, symlinks=True)
139
+ # `ignore=_ignore_symlinks` drops nested symlinks so they are
140
+ # never reproduced in the output tree. A malicious pack with a
141
+ # symlink to /etc/passwd cannot exfiltrate the target.
142
+ shutil.copytree(entry, destination, ignore=_ignore_symlinks)
132
143
 
133
144
 
134
145
  def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
@@ -29,6 +29,17 @@ from agentbundle.build.projections.codex_agent_toml import (
29
29
  )
30
30
 
31
31
 
32
+ def _ignore_symlinks(directory: str, names: list[str]) -> set[str]:
33
+ """`shutil.copytree` ignore callback: skip every symlink member.
34
+
35
+ Drops nested symlinks so they are never reproduced in the output
36
+ tree. The top-level `is_symlink()` skip in `project_packs` covers
37
+ the skill root; this covers the subtree.
38
+ """
39
+ base = Path(directory)
40
+ return {name for name in names if (base / name).is_symlink()}
41
+
42
+
32
43
  def _iter_primitives(contract: dict) -> Iterator[str]:
33
44
  """Yield Codex's projected primitive names in phase order."""
34
45
  adapter_block = contract["adapter"]["codex"]
@@ -131,11 +142,11 @@ def project_packs(pack_paths: list[Path], contract: dict, output_root: Path) ->
131
142
  destination.unlink()
132
143
  elif destination.exists():
133
144
  shutil.rmtree(destination)
134
- # symlinks=True keeps source symlinks as
135
- # symlinks — never dereferences. A malicious
136
- # pack with a symlink to /etc/passwd cannot
137
- # exfiltrate.
138
- shutil.copytree(entry, destination, symlinks=True)
145
+ # `ignore=_ignore_symlinks` drops nested symlinks
146
+ # entirely — never reproduced in the output tree.
147
+ # A malicious pack with a symlink to /etc/passwd
148
+ # cannot exfiltrate.
149
+ shutil.copytree(entry, destination, ignore=_ignore_symlinks)
139
150
  # Bound to `skill` only per spec § Never do. Other
140
151
  # direct-directory primitives opt in explicitly.
141
152
  if primitive_name == "skill":
@@ -34,6 +34,17 @@ from agentbundle.build.projections.copilot_hooks_json import (
34
34
  )
35
35
 
36
36
 
37
+ def _ignore_symlinks(directory: str, names: list[str]) -> set[str]:
38
+ """`shutil.copytree` ignore callback: skip every symlink member.
39
+
40
+ Drops nested symlinks so they are never reproduced in the output
41
+ tree. The top-level `is_symlink()` skip in `_project_direct_directory`
42
+ covers the skill root; this covers the subtree.
43
+ """
44
+ base = Path(directory)
45
+ return {name for name in names if (base / name).is_symlink()}
46
+
47
+
37
48
  def _iter_primitives(contract: dict) -> Iterator[str]:
38
49
  """Yield Copilot's projected primitive names in phase order."""
39
50
  adapter_block = contract["adapter"]["copilot"]
@@ -86,13 +97,13 @@ def _project_direct_directory(
86
97
  """Copy each `<name>/` source tree to the target directory verbatim, then
87
98
  sweep orphaned target dirs no longer backed by a source.
88
99
 
89
- Mirrors codex's `direct-directory` branch: `symlinks=True` keeps source
90
- symlinks as symlinks (never dereferenced), a symlink at the entry root is
91
- skipped (defense-in-depth `lint-packs` already refuses symlinked packs,
92
- but a direct `project()` caller bypasses that gate), and a destination
93
- symlink is `unlink`ed (never `rmtree`d) before the copy. The orphan sweep is
94
- **bounded to the `skill` primitive's** expected source names, so it can
95
- never delete sibling `.github/agents/` or `.github/hooks/` content.
100
+ A symlink at the entry root is skipped (defense-in-depth `lint-packs`
101
+ already refuses symlinked packs, but a direct `project()` caller bypasses
102
+ that gate). `ignore=_ignore_symlinks` drops nested symlinks so they are
103
+ never reproduced in the output tree. A destination symlink is `unlink`ed
104
+ (never `rmtree`d) before the copy. The orphan sweep is **bounded to the
105
+ `skill` primitive's** expected source names, so it can never delete sibling
106
+ `.github/agents/` or `.github/hooks/` content.
96
107
  """
97
108
  target_dir = output_root / rule["target-path"].rstrip("/")
98
109
  target_dir.mkdir(parents=True, exist_ok=True)
@@ -107,6 +118,6 @@ def _project_direct_directory(
107
118
  destination.unlink()
108
119
  elif destination.exists():
109
120
  shutil.rmtree(destination)
110
- shutil.copytree(entry, destination, symlinks=True)
121
+ shutil.copytree(entry, destination, ignore=_ignore_symlinks)
111
122
  if primitive_name == "skill":
112
123
  sweep_orphans(target_dir, expected_names)
@@ -143,8 +143,8 @@ def _ignore_symlinks(directory: str, names: list[str]) -> set[str]:
143
143
  secret the symlink points at) into the projection. The top-level
144
144
  `is_symlink()` skip in `_project_direct_directory` only covers the skill
145
145
  root; this covers the subtree. (Build runs on trusted `packs/`; this is
146
- the install-from-untrusted-catalogue defense — see docs/backlog.md for the
147
- cross-adapter shared-helper follow-on.)
146
+ the install-from-untrusted-catalogue defense — mirrored in all five
147
+ direct-directory adapters.)
148
148
  """
149
149
  base = Path(directory)
150
150
  return {name for name in names if (base / name).is_symlink()}
@@ -369,6 +369,17 @@ def _project_hook_wiring_to_agent_json(
369
369
  # ---------------------------------------------------------------------------
370
370
 
371
371
 
372
+ def _ignore_symlinks(directory: str, names: list[str]) -> set[str]:
373
+ """`shutil.copytree` ignore callback: skip every symlink member.
374
+
375
+ Drops nested symlinks so they are never reproduced in the output
376
+ tree. The top-level `is_symlink()` skip in `_project_direct_directory`
377
+ covers the skill root; this covers the subtree.
378
+ """
379
+ base = Path(directory)
380
+ return {name for name in names if (base / name).is_symlink()}
381
+
382
+
372
383
  def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
373
384
  for entry in sorted(source_dir.iterdir()):
374
385
  # Defense-in-depth — `lint-packs` rejects packs that ship
@@ -386,7 +397,9 @@ def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
386
397
  destination.unlink()
387
398
  elif destination.exists():
388
399
  shutil.rmtree(destination)
389
- shutil.copytree(entry, destination, symlinks=True)
400
+ # `ignore=_ignore_symlinks` drops nested symlinks so they are
401
+ # never reproduced in the output tree.
402
+ shutil.copytree(entry, destination, ignore=_ignore_symlinks)
390
403
 
391
404
 
392
405
  def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
@@ -18,6 +18,7 @@ paths.
18
18
  from __future__ import annotations
19
19
 
20
20
  import json
21
+ import re
21
22
  import shutil
22
23
  import sys
23
24
  import tomllib
@@ -33,6 +34,22 @@ PACKAGE_ROOT = Path(__file__).resolve().parent
33
34
  RECIPES_DIR = PACKAGE_ROOT / "recipes"
34
35
  REPO_ROOT = PACKAGE_ROOT.parent.parent.parent.parent
35
36
 
37
+ # Canonical branch name for the published Claude-plugins distribution.
38
+ # All marketplace entries' source.branch must match this value so `claude plugin
39
+ # install` fetches from the right branch.
40
+ _DIST_BRANCH = "claude-plugins-dist"
41
+
42
+ # Marketplace description included in both the dist and self-hosted marketplace.json.
43
+ # The description field is required for `claude plugin validate --strict` to pass.
44
+ _MARKETPLACE_DESCRIPTION = (
45
+ "Agent skills, subagents, and hooks for Claude Code and other coding agents."
46
+ )
47
+
48
+ # Match https://github.com/owner/repo (optional trailing .git and slash).
49
+ _GITHUB_URL_RE = re.compile(
50
+ r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$"
51
+ )
52
+
36
53
 
37
54
  def _bundled_or_repo(name: str) -> Path:
38
55
  """Locate a data file shipped under both `agentbundle/_data/` and
@@ -187,8 +204,11 @@ def derive_projectable_subset(pack_toml: dict) -> dict:
187
204
  subset into the claude-plugins + apm routes (the ``plugin.json`` /
188
205
  ``marketplace.json`` entry). Fixed mapping:
189
206
 
190
- - ``author`` ← first ``[[pack.maintainers]]``, rendered
191
- ``"Name <email>"`` (name alone when no email).
207
+ - ``author`` ← first ``[[pack.maintainers]]``, as object
208
+ ``{"name": ..., "email": ...}`` (email omitted when absent).
209
+ - ``source`` ← derived from ``[pack.links].repository`` when it is a
210
+ GitHub URL: ``{"source": "github", "repo": "owner/name",
211
+ "branch": _DIST_BRANCH, "directory": pack.name}``.
192
212
  - ``license`` ← ``[pack].license`` (verbatim).
193
213
  - ``homepage`` ← ``[pack.links].homepage`` (verbatim).
194
214
  - ``repository`` ← ``[pack.links].repository`` (verbatim).
@@ -214,10 +234,10 @@ def derive_projectable_subset(pack_toml: dict) -> dict:
214
234
  name = first.get("name")
215
235
  email = first.get("email")
216
236
  if isinstance(name, str) and name:
237
+ author_obj: dict = {"name": name}
217
238
  if isinstance(email, str) and email:
218
- out["author"] = f"{name} <{email}>"
219
- else:
220
- out["author"] = name
239
+ author_obj["email"] = email
240
+ out["author"] = author_obj
221
241
 
222
242
  license_ = pack.get("license")
223
243
  if isinstance(license_, str) and license_:
@@ -231,6 +251,18 @@ def derive_projectable_subset(pack_toml: dict) -> dict:
231
251
  repository = links.get("repository")
232
252
  if isinstance(repository, str) and repository:
233
253
  out["repository"] = repository
254
+ # Derive source field: tells Claude Code where to fetch the plugin
255
+ # from the canonical distribution branch.
256
+ pack_name = pack.get("name", "")
257
+ if isinstance(pack_name, str) and pack_name:
258
+ m = _GITHUB_URL_RE.match(repository)
259
+ if m:
260
+ out["source"] = {
261
+ "source": "github",
262
+ "repo": m.group(1),
263
+ "branch": _DIST_BRANCH,
264
+ "directory": pack_name,
265
+ }
234
266
 
235
267
  keywords = pack.get("keywords")
236
268
  if isinstance(keywords, list):
@@ -478,8 +510,22 @@ def _run_per_pack_single(
478
510
  destination.parent.mkdir(parents=True, exist_ok=True)
479
511
  # Load, splice in synthesised SessionStart hook, re-serialise.
480
512
  derived = json.loads(plugin_manifest.read_text(encoding="utf-8"))
513
+ # Claude Code 2.1.209+ hook contract: each event entry is an object
514
+ # with a nested "hooks" array of typed hook objects, not a flat
515
+ # {command} object. The old flat shape ({command}) is rejected with
516
+ # "hooks: Invalid input" by the plugin validator.
481
517
  derived["hooks"] = {
482
- "SessionStart": [{"command": _SESSION_START_COMMAND}]
518
+ "SessionStart": [
519
+ {
520
+ "hooks": [
521
+ {
522
+ "type": "command",
523
+ "command": _SESSION_START_COMMAND,
524
+ "timeout": 10,
525
+ }
526
+ ]
527
+ }
528
+ ]
483
529
  }
484
530
  # enriched-pack-manifest: merge the projectable metadata subset derived
485
531
  # from this pack's pack.toml (emit-only-when-present, so a legacy pack
@@ -490,6 +536,12 @@ def _run_per_pack_single(
490
536
  pack_toml_for_subset.read_text(encoding="utf-8")
491
537
  )
492
538
  derived.update(derive_projectable_subset(pack_meta))
539
+ # Strip marketplace-only fields from the per-pack plugin.json.
540
+ # "source" and "category" belong only in marketplace.json entries;
541
+ # the Claude plugin validator warns they are ignored in plugin.json.
542
+ # _run_aggregate re-adds them from pack.toml when building marketplace.json.
543
+ for _marketplace_key in ("source", "category"):
544
+ derived.pop(_marketplace_key, None)
493
545
  # Validate the derived manifest IN MEMORY before writing to disk
494
546
  # (Blocker-3: pre-write validation so a synthesis bug never lands
495
547
  # a malformed plugin.json in dist/).
@@ -624,12 +676,52 @@ def _run_aggregate(recipe: Recipe, output_dir: Path) -> dict:
624
676
  for plugin_dir in sorted(input_dir.iterdir()):
625
677
  manifest = plugin_dir / ".claude-plugin" / "plugin.json"
626
678
  if manifest.exists():
627
- entries.append(json.loads(manifest.read_text(encoding="utf-8")))
679
+ entry = json.loads(manifest.read_text(encoding="utf-8"))
680
+ # hooks are per-plugin installation artifacts, not marketplace
681
+ # metadata; strip them from marketplace entries.
682
+ entry.pop("hooks", None)
683
+ # Re-add marketplace-only fields (source, category) from the
684
+ # projected pack.toml (also present in the dist directory).
685
+ # These were stripped from plugin.json to keep it clean of
686
+ # fields the Claude plugin loader ignores at runtime.
687
+ pack_toml_path = plugin_dir / "pack.toml"
688
+ if pack_toml_path.exists():
689
+ pack_meta = tomllib.loads(
690
+ pack_toml_path.read_text(encoding="utf-8")
691
+ )
692
+ subset = derive_projectable_subset(pack_meta)
693
+ for _mk in ("source", "category"):
694
+ if _mk in subset:
695
+ entry[_mk] = subset[_mk]
696
+ entries.append(entry)
628
697
  output_path = output_dir / recipe.output_file
629
698
  _assert_under(output_path, output_dir)
630
699
  output_path.parent.mkdir(parents=True, exist_ok=True)
700
+
701
+ # Derive marketplace name and owner from the first entry that carries a
702
+ # source.repo field (populated above from pack.toml via derive_projectable_subset).
703
+ marketplace_name: str | None = None
704
+ marketplace_owner: dict | None = None
705
+ for entry in entries:
706
+ src = entry.get("source")
707
+ if isinstance(src, dict):
708
+ repo = src.get("repo", "")
709
+ if isinstance(repo, str) and "/" in repo:
710
+ owner_part, name_part = repo.split("/", 1)
711
+ marketplace_name = name_part
712
+ marketplace_owner = {"name": owner_part}
713
+ break
714
+
715
+ payload: dict = {}
716
+ if marketplace_name:
717
+ payload["name"] = marketplace_name
718
+ payload["description"] = _MARKETPLACE_DESCRIPTION
719
+ if marketplace_owner:
720
+ payload["owner"] = marketplace_owner
721
+ payload["plugins"] = entries
722
+
631
723
  output_path.write_text(
632
- json.dumps({"plugins": entries}, indent=2, sort_keys=True) + "\n",
724
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
633
725
  encoding="utf-8", newline="\n",
634
726
  )
635
727
  return {"recipe": recipe.name, "type": recipe.type, "entries": len(entries)}
@@ -28,8 +28,8 @@ dollar-amount in prose or a fenced shell example (``$10/month``, or a ``$1``
28
28
  inside a fenced code block) trips it too. This is **fail-closed** (a loud build
29
29
  error an author can act on, never a silent bad emit), and no shipped command
30
30
  contains a ``$<digit>``. An injection-context-aware parser (skip fenced/inline code, require
31
- an injection-shaped token) is a documented follow-on — see ``docs/backlog.md``
32
- § ``gemini-full-parity``. Until then: keep literal ``$<digit>`` out of command
31
+ an injection-shaped token) is a documented follow-on — see ``workspace.toml [backlog]``
32
+ slug ``gemini-full-parity``. Until then: keep literal ``$<digit>`` out of command
33
33
  bodies, or the build will refuse the command.
34
34
 
35
35
  The one-source-file → one-output-file shape and the frontmatter helpers mirror
@@ -362,14 +362,13 @@ EXCLUDED_PATTERNS: tuple[str, ...] = (
362
362
  "docs/product/*.md",
363
363
  "docs/knowledge/*.md",
364
364
  "docs/guides/**/*.md",
365
+ # Seeded-once / adopter-curated files (RFC-0002 Manual semantics).
366
+ "workspace.toml", # RFC-0069: seeded once; adopter-curated thereafter
365
367
  # Manual seed-projected paths (RFC-0002 amendment 2026-05-25). The
366
368
  # `docs/<area>/*.md` patterns above cover 11 of the 19 reclassified
367
- # paths; the following 8 are not matched by any pattern and need
369
+ # paths; the following 7 are not matched by any pattern and need
368
370
  # explicit listing. See `docs/specs/self-hosting/spec.md` AC20.
369
371
  "docs/CHARTER.md",
370
- "docs/backlog.md", # per-instance work/deferral register; seed is
371
- # placeholder, on-disk file is the curated instance
372
- # (RFC-0016 mechanism 5). Preserved like docs/product/*.
373
372
  "docs/knowledge/patterns.jsonl",
374
373
  "docs/rfc/README.md",
375
374
  "docs/adr/README.md",
@@ -557,12 +556,14 @@ def _aggregate_marketplace(
557
556
  packs_dir: Path,
558
557
  output_root: Path,
559
558
  owner: str = "eugenelim",
559
+ name: str = "agent-ready-repo",
560
+ description: str = "Agent skills, subagents, and hooks for Claude Code and other coding agents.",
560
561
  ) -> Path:
561
562
  """Aggregate `packs/*/.claude-plugin/plugin.json` into
562
563
  `output_root/.claude-plugin/marketplace.json` so this repo is itself
563
- a usable marketplace at HEAD. `owner` defaults to this repo's
564
- concrete value but `run_self_host` overrides it from
565
- `.adapt-discovery.toml[adapt].owner` so adopters get their own."""
564
+ a usable marketplace at HEAD. `owner` and `name` default to this repo's
565
+ concrete values but `run_self_host` overrides them from
566
+ `.adapt-discovery.toml` so adopters get their own."""
566
567
  entries: list[dict] = []
567
568
  for pack_path in sorted(packs_dir.iterdir()):
568
569
  if not pack_path.is_dir() or not (pack_path / "pack.toml").exists():
@@ -570,10 +571,12 @@ def _aggregate_marketplace(
570
571
  manifest = pack_path / ".claude-plugin" / "plugin.json"
571
572
  if manifest.exists():
572
573
  entry = json.loads(manifest.read_text(encoding="utf-8"))
574
+ # hooks are per-plugin artifacts; strip them from marketplace entries.
575
+ entry.pop("hooks", None)
573
576
  # enriched-pack-manifest: surface the projectable metadata subset
574
- # (author / license / links / keywords / category / displayName)
575
- # derived from pack.toml, so the catalogue entry is described
576
- # richly. Emit-only-when-present keeps legacy entries byte-identical.
577
+ # (author / license / links / keywords / category / displayName /
578
+ # source) derived from pack.toml. Emit-only-when-present keeps
579
+ # legacy entries byte-identical.
577
580
  pack_meta = tomllib.loads(
578
581
  (pack_path / "pack.toml").read_text(encoding="utf-8")
579
582
  )
@@ -582,6 +585,8 @@ def _aggregate_marketplace(
582
585
  target = output_root / ".claude-plugin" / "marketplace.json"
583
586
  target.parent.mkdir(parents=True, exist_ok=True)
584
587
  payload = {
588
+ "name": name,
589
+ "description": description,
585
590
  "owner": {"name": owner},
586
591
  "plugins": entries,
587
592
  }
@@ -1079,6 +1084,7 @@ def run_self_host(
1079
1084
  return 3
1080
1085
  discovery_flat = dict(discovery.markers)
1081
1086
  owner = discovery_flat.get("owner", "eugenelim")
1087
+ marketplace_name = discovery_flat.get("project-name", "agent-ready-repo")
1082
1088
 
1083
1089
  if dry_run:
1084
1090
  with tempfile.TemporaryDirectory(prefix="agentbundle-shadow-") as shadow_str:
@@ -1096,7 +1102,7 @@ def run_self_host(
1096
1102
  except ValueError as exc:
1097
1103
  print(f"self-host: {exc}", file=sys.stderr)
1098
1104
  return 4
1099
- _aggregate_marketplace(packs_dir, shadow, owner=owner)
1105
+ _aggregate_marketplace(packs_dir, shadow, owner=owner, name=marketplace_name)
1100
1106
  _recreate_claude_symlink(shadow, force_copy=no_symlink)
1101
1107
  extra_marker_paths = list(seed_map.keys()) + [
1102
1108
  Path(".claude-plugin") / "marketplace.json",
@@ -1152,7 +1158,7 @@ def run_self_host(
1152
1158
  except ValueError as exc:
1153
1159
  print(f"self-host: {exc}", file=sys.stderr)
1154
1160
  return 4
1155
- _aggregate_marketplace(packs_dir, working_tree, owner=owner)
1161
+ _aggregate_marketplace(packs_dir, working_tree, owner=owner, name=marketplace_name)
1156
1162
  _recreate_claude_symlink(working_tree, force_copy=no_symlink)
1157
1163
  extra_marker_paths = list(seed_map.keys()) + [
1158
1164
  Path(".claude-plugin") / "marketplace.json",