Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion config/runtime-targets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`. Its version is `1.0.0+<source commit>`,
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
Expand Down
21 changes: 21 additions & 0 deletions scripts/compile-runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/SKILL.md`` one level
deep, so the nested ``skills/<name>/`` 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:<name>`` 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()
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/test_compile_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading