From e40e552abfdf445481dbc192374668df5f68eab1 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Tue, 12 May 2026 01:27:11 +0200 Subject: [PATCH 1/7] repackage claude/lightcone/ as a Claude Code plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill bundle was already plugin-shaped — skills/, agents/, hooks.json, scripts/. What it lacked was the manifest layer that lets `claude plugin install` find and load it. This adds: - `claude/lightcone/.claude-plugin/plugin.json` — the plugin manifest - `claude/lightcone/hooks/hooks.json` (moved from `claude/lightcone/hooks.json`) with command paths rewritten from `${CLAUDE_PROJECT_DIR}/.claude/scripts/X` to `${CLAUDE_PLUGIN_ROOT}/scripts/X`. Keeping the directory name `scripts/` rather than renaming to `hooks-handlers/` (the canonical pattern) is a judgment call for minimum churn — the names are already cleanly descriptive and the constitution flagged this explicitly as an option. - `.claude-plugin/marketplace.json` at the repo root, declaring one plugin (`lightcone`) sourced from `./claude/lightcone` - A second `force-include` line in `pyproject.toml` so the marketplace manifest ships in the wheel alongside the plugin directory `lc init` now shells out to `claude plugin marketplace add ` + `claude plugin install lightcone@lightcone-cli` instead of `shutil.copytree`-ing each subdirectory into the project's `.claude/`. The project-scoped `.claude/settings.json` keeps only the permissions tier; the plugin (skills, agents, hooks) installs user-scoped, mirroring felt's `setup.go` pattern. When `claude` isn't on PATH (Codex users, agents that consume the plugin differently), `lc init` prints a manual-install hint and continues. The plugin still ships in the wheel; only the auto-install is skipped. `get_plugin_source_dir()` is renamed to `get_marketplace_root()` to reflect its new semantics — it returns the directory containing `.claude-plugin/marketplace.json` (the wheel-installed package root or, in dev, the repo root), which is what `claude plugin marketplace add` consumes. Tests: - Autouse fixture monkeypatches `shutil.which("claude")` to None by default so the test suite doesn't write to the user's real `~/.claude/` config. - `test_init_writes_settings_with_only_permissions` pins the new shape. - `test_init_invokes_claude_plugin_when_available` asserts the two CLI calls. - `test_init_prints_hint_when_claude_missing` covers the soft-fail path. The directory `claude/lightcone/` deliberately stays where it is rather than moving to a more conventional plugin-root like `claude-plugin/` (as in felt). Keeping the path minimises churn across docs/, the `force-include` rule, and every external link into the bundle's source. Plugin conventions dictate manifest *location* and *contents*, not the directory path the plugin lives at. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 93b2d6d4507cd2bcc9fbb6b54134acb43e8a7935) --- .claude-plugin/marketplace.json | 16 +++ claude/lightcone/.claude-plugin/plugin.json | 13 +++ claude/lightcone/{ => hooks}/hooks.json | 6 +- pyproject.toml | 7 ++ src/lightcone/cli/commands.py | 106 +++++++++++++++----- src/lightcone/cli/plugin.py | 47 ++++++--- tests/test_cli.py | 98 ++++++++++++++++++ 7 files changed, 249 insertions(+), 44 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 claude/lightcone/.claude-plugin/plugin.json rename claude/lightcone/{ => hooks}/hooks.json (59%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..c3435a72 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "lightcone-cli", + "description": "Lightcone Research's agentic layer for ASTRA — Claude Code plugin shipping the /lc-* skills, /astra and /lc-cli reference skills, lc-extractor subagent, and session hooks that integrate Claude Code with the lc workflow.", + "owner": { + "name": "Lightcone Research", + "url": "https://github.com/LightconeResearch" + }, + "plugins": [ + { + "name": "lightcone", + "description": "Skills, agents, and hooks for working on ASTRA projects in Claude Code. Installed by `lc init` (which shells out to `claude plugin install lightcone@lightcone-cli`).", + "source": "./claude/lightcone" + } + ] +} diff --git a/claude/lightcone/.claude-plugin/plugin.json b/claude/lightcone/.claude-plugin/plugin.json new file mode 100644 index 00000000..d8500669 --- /dev/null +++ b/claude/lightcone/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "lightcone", + "version": "0.1.0", + "description": "Lightcone Research's agentic layer for ASTRA. Bundles the /lc-* skills (lc-new, lc-from-code, lc-from-paper, lc-feedback, ralph, and the paper-reproduction sibling bundle), the /astra and /lc-cli reference skills, the lc-extractor literature subagent, and SessionStart + PostToolUse hooks that prepend the project venv to PATH, surface validation status, and auto-validate astra.yaml edits.", + "author": { + "name": "Lightcone Research", + "url": "https://github.com/LightconeResearch" + }, + "homepage": "https://lightconeresearch.github.io/lightcone-cli/", + "repository": "https://github.com/LightconeResearch/lightcone-cli", + "license": "BSD-3-Clause", + "keywords": ["astra", "lightcone", "research", "reproducibility", "snakemake"] +} diff --git a/claude/lightcone/hooks.json b/claude/lightcone/hooks/hooks.json similarity index 59% rename from claude/lightcone/hooks.json rename to claude/lightcone/hooks/hooks.json index 46eccfd9..77dc8beb 100644 --- a/claude/lightcone/hooks.json +++ b/claude/lightcone/hooks/hooks.json @@ -4,12 +4,12 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/scripts/activate-venv.sh", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/activate-venv.sh\"", "timeout": 5 }, { "type": "command", - "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/scripts/session-start.sh", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh\"", "timeout": 15 } ] @@ -21,7 +21,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/scripts/validate-on-save.sh", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/validate-on-save.sh\"", "timeout": 15 } ] diff --git a/pyproject.toml b/pyproject.toml index 93265ef8..b6ce7b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,14 +59,21 @@ packages = [ "src/snakemake_executor_plugin_dask", ] +# The plugin bundle lives at claude/lightcone/. The marketplace manifest at +# the repo root (.claude-plugin/marketplace.json) points at it; both ship +# inside the wheel so `lc init` can shell out to `claude plugin marketplace +# add ` and `claude plugin install +# lightcone@lightcone-cli` without needing a separate checkout. [tool.hatch.build.targets.wheel.force-include] "claude/lightcone" = "lightcone/cli/claude/lightcone" +".claude-plugin/marketplace.json" = "lightcone/cli/.claude-plugin/marketplace.json" [tool.hatch.build.targets.sdist] include = [ "src/lightcone", "src/snakemake_executor_plugin_dask", "claude/lightcone", + ".claude-plugin/marketplace.json", ] [tool.ruff] diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index c1870711..50be7fdc 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -30,7 +30,11 @@ import yaml from rich.console import Console -from lightcone.cli.plugin import get_plugin_source_dir +from lightcone.cli.plugin import ( + MARKETPLACE_NAME, + PLUGIN_NAME, + get_marketplace_root, +) console = Console() logger = logging.getLogger(__name__) @@ -223,10 +227,13 @@ def init( # results/ directory placeholder (directory / "results").mkdir(exist_ok=True) - # Claude Code plugin bundle - plugin_source = get_plugin_source_dir() - if plugin_source is not None and plugin_source.exists(): - _install_claude_plugin(directory, plugin_source, permissions) + # Claude Code plugin: write the project's permission tier into + # .claude/settings.json, then shell out to the claude CLI so the plugin + # (skills, agents, hooks) lives in the user's global Claude Code config — + # not duplicated into every project. Soft-fails when claude isn't on PATH + # so users on other agents (codex, …) get a clear pointer rather than a + # hard error from `lc init`. + _install_claude_plugin(directory, permissions) # Project CLAUDE.md (a stub) (directory / "CLAUDE.md").write_text(_PROJECT_CLAUDE_MD) @@ -364,32 +371,77 @@ def init( """ -def _install_claude_plugin( - project_dir: Path, - plugin_source: Path, - permissions: str, -) -> None: - """Copy the bundled Claude Code plugin into the project's ``.claude/``. +def _install_claude_plugin(project_dir: Path, permissions: str) -> None: + """Wire up the Claude Code plugin for this project. + + Two things happen here, in this order: + + 1. Write ``.claude/settings.json`` with the chosen ``--permissions`` tier. + This file is project-scoped — it lives next to ``astra.yaml`` and only + controls what tools the agent may invoke in *this* project. + + 2. Shell out to the ``claude`` CLI to register the marketplace and + install the lightcone plugin. The plugin (skills, agents, hooks) is + *user-scoped* — Claude Code installs it under ``~/.claude/`` and + activates it in every session, including this one. Idempotent: a + second ``lc init`` (in a different project) is a no-op for the plugin. - The hook configuration ships with the plugin as ``hooks.json`` so - that hook entries live next to the scripts they reference. The CLI - only owns the ``--permissions`` tier selection. + Both ``claude plugin marketplace add`` and ``claude plugin install`` are + idempotent, mirroring the felt setup pattern that this lift models on. + When the ``claude`` CLI isn't on PATH we print a manual-install hint and + continue — Codex users (and anyone consuming the plugin via a future + npx-skills bridge) shouldn't have ``lc init`` hard-fail on them. """ claude_dir = project_dir / ".claude" claude_dir.mkdir(exist_ok=True) - for sub in ("skills", "agents", "scripts", "guides", "templates"): - src = plugin_source / sub - if src.exists(): - dest = claude_dir / sub - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - hooks = json.loads((plugin_source / "hooks.json").read_text()) - settings = { - "permissions": PERMISSION_TIERS[permissions], - "hooks": hooks, - } - (claude_dir / "settings.json").write_text(json.dumps(settings, indent=2)) + (claude_dir / "settings.json").write_text( + json.dumps({"permissions": PERMISSION_TIERS[permissions]}, indent=2) + ) + + marketplace_root = get_marketplace_root() + if marketplace_root is None: + # Shouldn't happen for a normal install — the wheel force-includes + # marketplace.json and the dev path resolves from the repo root. Log + # loudly and continue so the rest of init still completes. + console.print( + "[yellow]⚠ Could not locate the lightcone Claude plugin marketplace " + "manifest. Plugin install skipped.[/yellow]" + ) + return + + plugin_ref = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + + if shutil.which("claude") is None: + console.print( + "[yellow]claude CLI not found on PATH — skipping plugin install.[/yellow]\n" + " To install manually once Claude Code is available, run:\n" + f" [cyan]claude plugin marketplace add {marketplace_root}[/cyan]\n" + f" [cyan]claude plugin install {plugin_ref}[/cyan]\n" + " (Codex / other-agent users can ignore this — the bundle still ships in the wheel.)" + ) + return + + # Surface the CLI's own stdout/stderr so the user sees the same status + # output Claude Code prints natively. Both commands are idempotent, so + # re-running `lc init` on subsequent projects (or after manual claude + # plugin work) doesn't double-install. + try: + subprocess.run( + ["claude", "plugin", "marketplace", "add", str(marketplace_root)], + check=False, + ) + subprocess.run( + ["claude", "plugin", "install", plugin_ref], + check=False, + ) + except OSError as e: + # Defensive: shutil.which found `claude` but exec failed (broken + # symlink, permission flip, race with an upgrade). Don't crash init. + console.print( + f"[yellow]⚠ Could not invoke `claude plugin install`: {e}[/yellow]\n" + f" Install manually: claude plugin marketplace add {marketplace_root} && " + f"claude plugin install {plugin_ref}" + ) # ============================================================================= diff --git a/src/lightcone/cli/plugin.py b/src/lightcone/cli/plugin.py index 903dd263..0f36c722 100644 --- a/src/lightcone/cli/plugin.py +++ b/src/lightcone/cli/plugin.py @@ -1,34 +1,53 @@ -"""Plugin bundle discovery — finds the Claude Code skills/hooks shipped with lightcone-cli. +"""Plugin marketplace discovery — finds the Claude Code marketplace shipped with lightcone-cli. -Kept deliberately leaf (no imports from :mod:`lightcone.cli.commands` or :mod:`lightcone.eval`) -so it can be used by both the CLI and the eval harness without introducing an import cycle. +The marketplace manifest lives at ``/.claude-plugin/marketplace.json`` and +points at the actual plugin under ``/claude/lightcone/``. ``lc init`` +shells out to ``claude plugin marketplace add `` followed by ``claude +plugin install lightcone@lightcone-cli``; this module returns the right +```` to pass to the CLI. + +Kept deliberately leaf (no imports from :mod:`lightcone.cli.commands` or +:mod:`lightcone.eval`) so it can be used by both the CLI and the eval harness +without introducing an import cycle. """ from __future__ import annotations from pathlib import Path +# Names declared in .claude-plugin/marketplace.json (the marketplace name) and +# claude/lightcone/.claude-plugin/plugin.json (the plugin name). The install +# reference passed to ``claude plugin install`` is ``PLUGIN@MARKETPLACE``. +MARKETPLACE_NAME = "lightcone-cli" +PLUGIN_NAME = "lightcone" + + +def get_marketplace_root() -> Path | None: + """Find the directory containing ``.claude-plugin/marketplace.json``. -def get_plugin_source_dir() -> Path | None: - """Find the lightcone Claude plugin source directory. + The returned path is what ``claude plugin marketplace add`` registers; the + Claude CLI then reads ``marketplace.json`` from that root and discovers + the plugin at ``./claude/lightcone``. - Looks for the plugin files in: + Looks in two locations, in order: - 1. Bundled location (installed package): ``lightcone/cli/claude/lightcone/`` - 2. Development location (repo): ``claude/lightcone/`` relative to repo root + 1. **Bundled** (installed wheel): ``lightcone/cli/`` — populated by the + ``force-include`` rules in ``pyproject.toml`` so the marketplace root + is reachable without a checkout. + 2. **Development** (running from repo): the repo root, three levels above + ``lightcone/cli/`` in the src-layout. """ import lightcone.cli package_dir = Path(lightcone.cli.__file__).parent - bundled_plugin = package_dir / "claude" / "lightcone" - if bundled_plugin.exists(): - return bundled_plugin + bundled_root = package_dir + if (bundled_root / ".claude-plugin" / "marketplace.json").is_file(): + return bundled_root # Try development location (running from repo) # package_dir == /src/lightcone/cli → parents[2] == repo_root = package_dir.parents[2] - dev_plugin = repo_root / "claude" / "lightcone" - if dev_plugin.exists(): - return dev_plugin + if (repo_root / ".claude-plugin" / "marketplace.json").is_file(): + return repo_root return None diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f5f0053..86aca183 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,6 +27,27 @@ def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return fake_home +@pytest.fixture(autouse=True) +def _no_real_claude_plugin_install(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent tests from shelling out to a real ``claude plugin install``. + + ``lc init`` calls ``shutil.which("claude")`` to decide whether to register + the marketplace and install the plugin via the Claude Code CLI. In CI and + on dev machines that have ``claude`` on PATH, the unmocked behavior would + write to the user's actual ``~/.claude/`` config. We default to the + soft-fail branch (print a hint, continue); tests that want to exercise + the install path override ``shutil.which`` and ``subprocess.run`` locally. + """ + real_which = shutil.which + + def fake_which(name: str, *args: object, **kwargs: object) -> str | None: + if name == "claude": + return None + return real_which(name, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(shutil, "which", fake_which) + + # ---- top-level ------------------------------------------------------------ @@ -106,6 +127,83 @@ def _fake_run(cmd: list[str], **kwargs: object) -> MagicMock: assert ["uv", "pip", "install", "--python", ".venv/bin/python", "lightcone-cli"] in calls +def test_init_writes_settings_with_only_permissions( + runner: CliRunner, tmp_path: Path +) -> None: + """The new ``lc init`` no longer copies skills/agents/scripts into the + project. ``.claude/settings.json`` carries only the permissions tier; the + plugin (skills, agents, hooks) lives in the user's ``~/.claude/`` after + ``claude plugin install``. + """ + import json + + project = tmp_path / "proj" + result = runner.invoke(main, ["init", str(project), "--no-git", "--no-venv"]) + assert result.exit_code == 0, result.output + + settings = json.loads((project / ".claude" / "settings.json").read_text()) + assert set(settings.keys()) == {"permissions"}, ( + "settings.json should only carry the permissions tier; the plugin " + "ships hooks via claude plugin install" + ) + # No per-project copies of plugin assets: + assert not (project / ".claude" / "skills").exists() + assert not (project / ".claude" / "agents").exists() + assert not (project / ".claude" / "scripts").exists() + assert not (project / ".claude" / "guides").exists() + assert not (project / ".claude" / "templates").exists() + + +def test_init_invokes_claude_plugin_when_available( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """When ``claude`` is on PATH, ``lc init`` shells out to register the + marketplace and install the plugin — both commands idempotent. + """ + calls: list[list[str]] = [] + + def _fake_run(cmd: list[str], **kwargs: object) -> MagicMock: + calls.append(list(cmd)) + return MagicMock(returncode=0) + + # `claude` resolves, `uv` does not (so the venv branch falls back to + # python -m venv, but we pass --no-venv anyway). + monkeypatch.setattr( + shutil, "which", lambda name: "/usr/bin/claude" if name == "claude" else None + ) + monkeypatch.setattr(subprocess, "run", _fake_run) + + project = tmp_path / "proj" + result = runner.invoke(main, ["init", str(project), "--no-git", "--no-venv"]) + assert result.exit_code == 0, result.output + + # The marketplace-add call's last arg is the marketplace root, which is + # either the bundled wheel path or the dev repo root — both end in a + # `.claude-plugin/marketplace.json`. We only assert the verb shape. + add_calls = [c for c in calls if c[:3] == ["claude", "plugin", "marketplace"]] + install_calls = [c for c in calls if c[:3] == ["claude", "plugin", "install"]] + assert add_calls, f"expected `claude plugin marketplace add` invocation, got {calls}" + assert add_calls[0][3] == "add" + assert install_calls == [["claude", "plugin", "install", "lightcone@lightcone-cli"]] + + +def test_init_prints_hint_when_claude_missing( + runner: CliRunner, tmp_path: Path +) -> None: + """When ``claude`` is missing (autouse fixture default), ``lc init`` still + succeeds and prints a manual-install hint instead of hard-failing. + + This is the path Codex / other-agent users hit — and we don't want a + missing ``claude`` CLI to break ``lc init`` for them. + """ + project = tmp_path / "proj" + result = runner.invoke(main, ["init", str(project), "--no-git", "--no-venv"]) + assert result.exit_code == 0, result.output + assert "claude CLI not found" in result.output + assert "claude plugin marketplace add" in result.output + assert "claude plugin install lightcone@lightcone-cli" in result.output + + def test_init_venv_falls_back_to_python_when_uv_missing( runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 6486900d78122847a09756ff423b1cb36b35c45e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Tue, 12 May 2026 01:32:04 +0200 Subject: [PATCH 2/7] bundle: switch in-skill hardcoded paths to ${CLAUDE_PLUGIN_ROOT} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the bundle was copied into each project's `.claude/` by `lc init`, skills could reference each other via project-relative paths like `.claude/skills/ralph/scripts/ralph`. With the bundle now installed as a user-scoped Claude Code plugin under `~/.claude/plugins/cache/lightcone-cli/`, those project-relative paths no longer resolve. Claude Code substitutes `${CLAUDE_PLUGIN_ROOT}` in skill bodies to the plugin's install path at session start, so every cross-skill path that used to be project-rooted now switches to: ${CLAUDE_PLUGIN_ROOT}/skills//scripts/