diff --git a/artifacts/audits/phase1-architecture/capability-reconciliation.json b/artifacts/audits/phase1-architecture/capability-reconciliation.json index 83fde25..cd4eec9 100644 --- a/artifacts/audits/phase1-architecture/capability-reconciliation.json +++ b/artifacts/audits/phase1-architecture/capability-reconciliation.json @@ -530,7 +530,7 @@ "post_switch_verifier": [ "python3", "-c", - "import json; from pathlib import Path; m=json.loads(Path('runtime-manifest.json').read_text()); assert len(m['command_adapters']) == 12; assert all(Path(x['path']).is_file() for x in m['command_adapters']); assert all((Path('skills') / x['alias'] / 'SKILL.md').is_file() for x in m['command_aliases'])" + "import json; from pathlib import Path; m=json.loads(Path('runtime-manifest.json').read_text()); assert len(m['command_adapters']) == 12; assert all(Path(x['path']).is_file() for x in m['command_adapters']); assert all((Path('skills') / x['alias'] / 'SKILL.md').is_file() for x in m['command_aliases']); p=json.loads(Path('.claude-plugin/plugin.json').read_text()); assert p['name'] == 'stack' and p['version'].endswith(m['source_commit'][:12])" ], "runtime": "claude" }, diff --git a/config/runtime-targets.json b/config/runtime-targets.json index 1c8f9f3..ccd413d 100644 --- a/config/runtime-targets.json +++ b/config/runtime-targets.json @@ -6,7 +6,7 @@ "name": "claude", "runtime": "claude", "destination": ".claude/skills/stack", - "post_switch_verifier": ["python3", "-c", "import json; from pathlib import Path; m=json.loads(Path('runtime-manifest.json').read_text()); assert len(m['command_adapters']) == 12; assert all(Path(x['path']).is_file() for x in m['command_adapters']); assert all((Path('skills') / x['alias'] / 'SKILL.md').is_file() for x in m['command_aliases'])"] + "post_switch_verifier": ["python3", "-c", "import json; from pathlib import Path; m=json.loads(Path('runtime-manifest.json').read_text()); assert len(m['command_adapters']) == 12; assert all(Path(x['path']).is_file() for x in m['command_adapters']); assert all((Path('skills') / x['alias'] / 'SKILL.md').is_file() for x in m['command_aliases']); p=json.loads(Path('.claude-plugin/plugin.json').read_text()); assert p['name'] == 'stack' and p['version'].endswith(m['source_commit'][:12])"] }, { "name": "codex", diff --git a/docs/runtime-publication.md b/docs/runtime-publication.md index f6813a0..ab7ee59 100644 --- a/docs/runtime-publication.md +++ b/docs/runtime-publication.md @@ -42,6 +42,14 @@ The live target contract is strict Claude/Codex parity: - Claude: `$HOME/.claude/skills/stack` - Codex: `$HOME/.codex/skills/stack` +Claude Code discovers skills only one level under `~/.claude/skills/`, so the +compiler also writes `.claude-plugin/plugin.json` into the Claude target. That +makes the directory a skills-dir plugin: Claude Code loads it in place, through +the installer's symlink, as `stack@skills-dir`, and exposes each staged skill, +command, and agent as `stack:`. Its version is `1.0.0+`, +and the Claude post-switch verifier checks it. No raw skill root, marketplace, +or plugin store is written; `claude plugin list` shows the loaded state. + From a clean checkout, the durable fresh-machine path is: ```sh diff --git a/scripts/compile-runtime.py b/scripts/compile-runtime.py index 437c592..a457691 100644 --- a/scripts/compile-runtime.py +++ b/scripts/compile-runtime.py @@ -599,6 +599,25 @@ def compile_private_overlays(overlay_path: Path, targets: list[dict[str, Any]], raise RuntimeError(f"private overlay validation failed for {target['name']}: {error}") from error +def write_claude_plugin_manifest(stage: Path, commit: str) -> None: + """Make the Claude target a skills-dir plugin. + + Claude Code discovers only ``~/.claude/skills//SKILL.md`` one level + deep, so the nested ``skills//`` tree is invisible on its own. A + plugin manifest at the target root makes Claude Code load the directory + in place as ``stack@skills-dir``, exposing every skill, command, and agent + as ``stack:`` without touching any raw skill root or plugin store. + """ + manifest = { + "name": "stack", + "version": f"1.0.0+{commit[:12]}", + "description": "Stack: curated design-and-build skills, commands, and agents compiled from thecolormaroun/stack.", + } + plugin_dir = stage / ".claude-plugin" + plugin_dir.mkdir() + (plugin_dir / "plugin.json").write_text(canonical_json(manifest), encoding="utf-8") + + def compile_runtimes(root: Path, catalog_path: Path, targets_path: Path, staging_root: Path, *, source_commit: str | None = None, private_overlay: Path | None = None, private_output_root: Path | None = None, package_cache: Path | None = None) -> dict[str, Path]: root = root.resolve() catalog_path = catalog_path.resolve() @@ -709,6 +728,8 @@ def compile_runtimes(root: Path, catalog_path: Path, targets_path: Path, staging copy_shared_tree(verification_artifacts, destination, mapping, label="private-overlay verification input") rewrite_relative_links(stage, mapping) validate_staged_links(stage) + if target["runtime"] == "claude": + write_claude_plugin_manifest(stage, commit) aliases = [ {"alias": alias, "canonical_target": entry["canonical_name"]} for entry in included diff --git a/tests/test_compile_runtime.py b/tests/test_compile_runtime.py index 71a44db..b2b9872 100644 --- a/tests/test_compile_runtime.py +++ b/tests/test_compile_runtime.py @@ -78,6 +78,33 @@ def test_active_entries_compile_deterministically_and_record_exclusions(self) -> self.assertEqual((first["codex"] / "runtime-manifest.json").read_bytes(), (second["codex"] / "runtime-manifest.json").read_bytes()) self.assertEqual((first["codex"] / "stage-attestation.json").read_bytes(), (second["codex"] / "stage-attestation.json").read_bytes()) + def test_claude_target_is_a_skills_dir_plugin_and_codex_is_not(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.write_skill(root, "active") + entry = capability("active", supported=["claude", "codex"]) + entry["runtimes"] = {"supported": ["claude", "codex"], "publish_targets": ["claude", "codex"]} + self.write_catalog(root, [entry]) + targets = root / "targets.json" + targets.write_text(json.dumps({ + "schema_version": 1, + "catalog_digest": COMPILER.digest_file(root / "registry/capabilities.json"), + "targets": [ + {"name": "claude", "runtime": "claude", "destination": "installed/claude"}, + {"name": "codex", "runtime": "codex", "destination": "installed/codex"}, + ], + }), encoding="utf-8") + stages = COMPILER.compile_runtimes(root, root / "registry/capabilities.json", targets, root / "runtime", source_commit="abc123def4567890") + + plugin = json.loads((stages["claude"] / ".claude-plugin/plugin.json").read_text()) + self.assertEqual(plugin["name"], "stack") + self.assertEqual(plugin["version"], "1.0.0+abc123def456") + self.assertTrue((stages["claude"] / "skills/active/SKILL.md").is_file()) + self.assertFalse((stages["codex"] / ".claude-plugin").exists()) + # The manifest is part of the attested public tree, not a post-hoc addition. + attestation = json.loads((stages["claude"] / "stage-attestation.json").read_text()) + self.assertEqual(attestation["staged_tree_digest"], COMPILER.staged_tree_digest(stages["claude"])) + def test_active_compatibility_alias_compiles_from_canonical_source(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary)