diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..c3435a7 --- /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.md b/CLAUDE.md index 1a5473d..a556eff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ src/lightcone/ # namespace — NO __init__.py ├── cli/ # Click surface │ ├── __init__.py # exposes main() │ ├── commands.py # init, run, status, verify, build -│ ├── plugin.py # get_plugin_source_dir +│ ├── plugin.py # get_marketplace_root + MARKETPLACE_NAME / PLUGIN_NAME constants │ └── claude/ # force-included Claude plugin bundle (in installed wheel only) ├── engine/ # execution substrate — Snakemake-based │ ├── __init__.py @@ -66,17 +66,23 @@ src/lightcone/ # namespace — NO __init__.py ├── cli.py # `lc eval` subcommand group ├── harness.py, sandbox.py, graders.py, build.py, report.py, models.py -claude/lightcone/ # Claude plugin source — force-included into the wheel +.claude-plugin/ # marketplace manifest (force-included into the wheel) +└── marketplace.json # declares the `lightcone` plugin sourced from ./claude/lightcone + +claude/lightcone/ # Claude Code plugin source — force-included into the wheel +├── .claude-plugin/plugin.json # plugin manifest (name=lightcone) ├── skills/ # lc-new, lc-from-code, lc-from-paper, │ # lc-feedback, ralph; │ # paper-reproduction bundle: lc-from-paper (entry), │ # ralph (loop substrate), narrative, │ # paper-extraction, figure-comparison, │ # check-sentence-by-sentence +│ # plus reference skills astra, lc-cli │ # (see skills/README.md for the full bundle map) ├── agents/ # lc-extractor -├── templates/ # Project CLAUDE.md template -└── scripts/ # Session hooks (bash): venv activation, validate-on-save, session-start primer +├── hooks/hooks.json # hook config (${CLAUDE_PLUGIN_ROOT}-rooted commands) +├── scripts/ # hook handlers (bash): activate-venv, session-start primer, validate-on-save +└── templates/ # Project CLAUDE.md template tests/ # pytest — mirrors src/ structure pyproject.toml # hatchling + hatch-vcs, ASTRA + Snakemake as deps diff --git a/README.md b/README.md index 3a923a3..5edec60 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ **lightcone-cli** (`lc`) is the agentic execution layer for [ASTRA](https://astra-spec.org/latest/) (Agentic Schema for Transparent -Research Analysis). Describe your analysis to an AI agent and `lc` takes -care of the rest — specification, execution, and provenance. +Research Analysis). Use it from Claude Code, Codex, or Pi: describe the +analysis you want, and `lc` keeps the specification, execution, and provenance +in sync. ## Quick Start @@ -18,9 +19,12 @@ care of the rest — specification, execution, and provenance. uv tool install lightcone-cli lc init my-analysis cd my-analysis -claude +claude # or: codex / pi ``` +`lc init` scaffolds the project and, when those CLIs are on your `PATH`, +installs the shared Lightcone bundle for Claude, Codex, and Pi. + Then tell the agent what you have to start from — a research question (`/lc-new`), existing code (`/lc-from-code`), or a paper to reproduce (`/lc-from-paper`). diff --git a/claude/lightcone/.claude-plugin/plugin.json b/claude/lightcone/.claude-plugin/plugin.json new file mode 100644 index 0000000..d850066 --- /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/.codex-plugin/plugin.json b/claude/lightcone/.codex-plugin/plugin.json new file mode 100644 index 0000000..7b85a7a --- /dev/null +++ b/claude/lightcone/.codex-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "lightcone", + "version": "0.1.0", + "description": "Lightcone Research's agentic layer for ASTRA. Bundles the lc-* skills, ASTRA and lc-cli reference skills, and hooks that integrate Codex sessions with the lc workflow.", + "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"], + "skills": "./skills/", + "hooks": "./hooks/hooks.json" +} diff --git a/claude/lightcone/extensions/lightcone.ts b/claude/lightcone/extensions/lightcone.ts new file mode 100644 index 0000000..c9d193e --- /dev/null +++ b/claude/lightcone/extensions/lightcone.ts @@ -0,0 +1,351 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { + createBashTool, + type ExtensionAPI, + type ExtensionContext, +} from "@earendil-works/pi-coding-agent"; + +const SESSION_CONTEXT_TYPE = "lightcone-session-start"; +const EXEC_TIMEOUT_MS = 15_000; +const SESSION_ERROR_TAIL_LINES = 20; +const VALIDATION_ERROR_TAIL_LINES = 40; + +const SKILL_ALIASES = [ + ["lc-new", "Scope a new ASTRA analysis from a research question"], + ["lc-from-code", "Wrap an existing codebase in ASTRA"], + ["lc-from-paper", "Reproduce a paper end-to-end in ASTRA"], + ["lc-feedback", "File a GitHub issue against the right Lightcone repo"], + ["ralph", "Run the ralph long-running loop against a constitution"], + ["paper-extraction", "Acquire a paper into the standardized ASTRA substrate"], + ["narrative", "Author ASTRA narrative and rationale prose"], + ["figure-comparison", "Build a paper-vs-reproduction comparison view"], + ["check-sentence-by-sentence", "Audit paper claims against code locations"], + ["astra", "Load the ASTRA specification reference skill"], + ["lc-cli", "Load the lc workflow reference skill"], +] as const; + +type ExecResult = { + stdout: string; + stderr: string; + code: number; +}; + +type StatusCounts = { + ok: number; + stale: number; + missing: number; + alias: number; +}; + +type ValidationTarget = { + projectRoot: string; + targetArg: string; + displayName: string; +}; + +function findAstraProjectRoot(start: string): string | null { + let current = path.resolve(start); + + while (true) { + if (fs.existsSync(path.join(current, "astra.yaml"))) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function getProjectVenv(projectRoot: string | null): { venv: string; bin: string } | null { + if (!projectRoot) { + return null; + } + + const venv = path.join(projectRoot, ".venv"); + const bin = path.join(venv, "bin"); + return fs.existsSync(bin) ? { venv, bin } : null; +} + +function withProjectVenvEnv(cwd: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const venv = getProjectVenv(findAstraProjectRoot(cwd)); + if (!venv) { + return env; + } + + const existingPath = env.PATH ?? process.env.PATH ?? ""; + return { + ...env, + VIRTUAL_ENV: venv.venv, + PATH: existingPath ? `${venv.bin}:${existingPath}` : venv.bin, + }; +} + +function resolveProjectExecutable(projectRoot: string, executable: string): string { + const candidate = path.join(projectRoot, ".venv", "bin", executable); + return fs.existsSync(candidate) ? candidate : executable; +} + +function combineOutput(result: ExecResult | undefined): string { + if (!result) { + return ""; + } + + return [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); +} + +function tailLines(text: string, maxLines: number): string { + const lines = text.split(/\r?\n/); + if (lines.length <= maxLines) { + return text.trim(); + } + + return `${lines.slice(-maxLines).join("\n")}\n[... ${lines.length - maxLines} earlier line(s) omitted]`; +} + +function isMissingCommand(result: ExecResult | undefined): boolean { + if (!result) { + return true; + } + + if (result.code === 127) { + return true; + } + + const combined = combineOutput(result); + return result.code !== 0 && /\b(not found|No such file or directory)\b/i.test(combined); +} + +function countMaterializationStatuses(statusJson: string): StatusCounts { + const counts: StatusCounts = { ok: 0, stale: 0, missing: 0, alias: 0 }; + + try { + const payload = JSON.parse(statusJson) as { + universes?: Array<{ outputs?: Array<{ status?: string }> }>; + }; + for (const universe of payload.universes ?? []) { + for (const output of universe.outputs ?? []) { + const status = output.status; + if (status === "ok" || status === "stale" || status === "missing" || status === "alias") { + counts[status] += 1; + } + } + } + } catch { + // Keep the zero counts — lc status can fail while the project is mid-edit. + } + + return counts; +} + +function hasPrimerMessage(ctx: ExtensionContext): boolean { + return ctx.sessionManager.getEntries().some((entry) => { + if (entry.type !== "message") { + return false; + } + const message = (entry as { message?: { customType?: string } }).message; + return message?.customType === SESSION_CONTEXT_TYPE; + }); +} + +function validationTargetFor(cwd: string, rawPath: string): ValidationTarget | null { + const cleanedPath = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath; + const absolutePath = path.resolve(cwd, cleanedPath); + const filename = path.basename(absolutePath); + const parent = path.basename(path.dirname(absolutePath)); + + if (filename === "astra.yaml") { + return { + projectRoot: path.dirname(absolutePath), + targetArg: "astra.yaml", + displayName: filename, + }; + } + + if (parent === "universes" && filename.endsWith(".yaml")) { + return { + projectRoot: path.dirname(path.dirname(absolutePath)), + targetArg: absolutePath, + displayName: filename, + }; + } + + return null; +} + +async function safeExec( + pi: ExtensionAPI, + command: string, + args: string[], + cwd: string, +): Promise { + return pi.exec(command, args, { cwd, timeout: EXEC_TIMEOUT_MS }).catch(() => undefined); +} + +async function buildSessionStartSummary( + pi: ExtensionAPI, + projectRoot: string, +): Promise { + const astra = resolveProjectExecutable(projectRoot, "astra"); + const lc = resolveProjectExecutable(projectRoot, "lc"); + const [validationResult, statusResult] = await Promise.all([ + safeExec(pi, astra, ["validate", "astra.yaml"], projectRoot), + safeExec(pi, lc, ["status", "--json"], projectRoot), + ]); + + if (isMissingCommand(validationResult) || isMissingCommand(statusResult)) { + return null; + } + + const counts = countMaterializationStatuses(statusResult?.stdout ?? ""); + const validationOk = (validationResult?.code ?? 1) === 0; + let summary = validationOk + ? "ASTRA project — validation: valid" + : "ASTRA project — validation: has errors"; + + summary += ` +Materialization: ok=${counts.ok} stale=${counts.stale} missing=${counts.missing} alias=${counts.alias} + +Substrate CLIs (use --help on any): + lc init / lc run / lc status / lc verify / lc build / lc export wrroc + astra validate / astra paper add / astra universe generate + +Reference skills (invoke when the surface above isn't enough): + /astra — astra.yaml spec: decisions, prior_insights, findings, evidence, sub-analyses, narrative anchors + /lc-cli — lc workflow: spec-code invariant, status interpretation, failure diagnosis`; + + if (!validationOk) { + const validationPreview = tailLines(combineOutput(validationResult), SESSION_ERROR_TAIL_LINES); + summary += ` + +Validation errors (run 'astra validate astra.yaml' for full output): +${validationPreview}`; + } + + const needsRun = counts.missing + counts.stale; + if (needsRun > 0) { + summary += ` + +ACTION REQUIRED: ${needsRun} output(s) need \`lc run\` (${counts.missing} missing, ${counts.stale} stale).`; + } + + return summary; +} + +async function buildValidationMessage( + pi: ExtensionAPI, + target: ValidationTarget, +): Promise { + const astra = resolveProjectExecutable(target.projectRoot, "astra"); + const result = await safeExec(pi, astra, ["validate", target.targetArg], target.projectRoot); + if (isMissingCommand(result)) { + return null; + } + + if ((result?.code ?? 1) === 0) { + return `ASTRA validation passed for ${target.displayName}`; + } + + const detail = tailLines(combineOutput(result), VALIDATION_ERROR_TAIL_LINES) || "(no validator output)"; + return `ASTRA validation FAILED for ${target.displayName}:\n${detail}`; +} + +function registerSkillAlias(pi: ExtensionAPI, skillName: string, description: string): void { + pi.registerCommand(skillName, { + description: `${description} (alias for /skill:${skillName})`, + handler: async (args) => { + const suffix = args.trim() ? ` ${args.trim()}` : ""; + pi.sendUserMessage(`/skill:${skillName}${suffix}`); + }, + }); +} + +export default function lightconeExtension(pi: ExtensionAPI) { + let primerInjected = false; + + for (const [skillName, description] of SKILL_ALIASES) { + registerSkillAlias(pi, skillName, description); + } + + pi.on("session_start", async (_event, ctx) => { + primerInjected = hasPrimerMessage(ctx); + }); + + const baseBashTool = createBashTool(process.cwd(), { + spawnHook: ({ command, cwd, env }) => ({ + command, + cwd, + env: withProjectVenvEnv(cwd, env), + }), + }); + + pi.registerTool({ + ...baseBashTool, + execute: async (toolCallId, params, signal, onUpdate, ctx) => { + const bashTool = createBashTool(ctx.cwd, { + spawnHook: ({ command, cwd, env }) => ({ + command, + cwd, + env: withProjectVenvEnv(cwd, env), + }), + }); + return bashTool.execute(toolCallId, params, signal, onUpdate); + }, + }); + + pi.on("before_agent_start", async (_event, ctx) => { + if (primerInjected) { + return; + } + + const projectRoot = findAstraProjectRoot(ctx.cwd); + if (!projectRoot) { + return; + } + + const summary = await buildSessionStartSummary(pi, projectRoot); + if (!summary) { + return; + } + + primerInjected = true; + return { + message: { + customType: SESSION_CONTEXT_TYPE, + content: summary, + display: false, + }, + }; + }); + + pi.on("tool_result", async (event, ctx) => { + if (event.isError || !["edit", "write"].includes(event.toolName)) { + return; + } + + const rawPath = (event.input as { path?: unknown }).path; + if (typeof rawPath !== "string") { + return; + } + + const target = validationTargetFor(ctx.cwd, rawPath); + if (!target) { + return; + } + + const message = await buildValidationMessage(pi, target); + if (!message) { + return; + } + + return { + content: Array.isArray(event.content) + ? [...event.content, { type: "text", text: message }] + : [{ type: "text", text: message }], + }; + }); +} diff --git a/claude/lightcone/hooks.json b/claude/lightcone/hooks.json deleted file mode 100644 index 46eccfd..0000000 --- a/claude/lightcone/hooks.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/scripts/activate-venv.sh", - "timeout": 5 - }, - { - "type": "command", - "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/scripts/session-start.sh", - "timeout": 15 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/scripts/validate-on-save.sh", - "timeout": 15 - } - ] - } - ] -} diff --git a/claude/lightcone/hooks/hooks.json b/claude/lightcone/hooks/hooks.json new file mode 100644 index 0000000..efd2ac8 --- /dev/null +++ b/claude/lightcone/hooks/hooks.json @@ -0,0 +1,32 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/activate-venv.sh\"", + "timeout": 5 + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh\"", + "timeout": 15 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/validate-on-save.sh\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/claude/lightcone/package.json b/claude/lightcone/package.json new file mode 100644 index 0000000..1aeb448 --- /dev/null +++ b/claude/lightcone/package.json @@ -0,0 +1,12 @@ +{ + "name": "lightcone-agent-bundle", + "private": true, + "pi": { + "extensions": [ + "./extensions/lightcone.ts" + ], + "skills": [ + "./skills/**/SKILL.md" + ] + } +} diff --git a/claude/lightcone/skills/README.md b/claude/lightcone/skills/README.md index 6d3a238..fe3e9d2 100644 --- a/claude/lightcone/skills/README.md +++ b/claude/lightcone/skills/README.md @@ -1,6 +1,6 @@ # lightcone-cli skills -Each subdirectory is one Claude Code skill: `SKILL.md` plus optional `references/`, `assets/`, and `scripts/`. `lc init` copies these into a project's `.claude/skills/` so they are discoverable to Claude Code sessions. +Each subdirectory is one Claude Code skill: `SKILL.md` plus optional `references/`, `assets/`, and `scripts/`. These ship as the `lightcone` Claude Code plugin (manifest at `claude/lightcone/.claude-plugin/plugin.json`). `lc init` shells out to `claude plugin marketplace add` + `claude plugin install lightcone@lightcone-cli` so the skills register user-scoped — discoverable in every Claude Code session, not duplicated into each project's `.claude/`. ## Project lifecycle skills @@ -23,7 +23,7 @@ Not direct entry points — Claude invokes these (or other skills invoke them) t ## Paper-reproduction bundle -A self-contained toolkit for reproducing published papers in ASTRA. The bundle is co-located so a single `lc init` brings the full toolkit into a project — no plugin marketplace, no separate installs. +A self-contained toolkit for reproducing published papers in ASTRA. The bundle is co-located inside the `lightcone` plugin so a single `lc init` brings the full toolkit — one marketplace registration, one plugin install, all skills available. | Skill | Role | |---|---| diff --git a/claude/lightcone/skills/lc-from-paper/SKILL.md b/claude/lightcone/skills/lc-from-paper/SKILL.md index 78e79db..eae9c13 100644 --- a/claude/lightcone/skills/lc-from-paper/SKILL.md +++ b/claude/lightcone/skills/lc-from-paper/SKILL.md @@ -77,7 +77,7 @@ After explicit user approval, `git init` the workdir if it isn't one already and After ORIENT lands, hand the rest of the reproduction off to a ralph loop. From the reproduction workdir: ```bash -.claude/skills/ralph/scripts/ralph constitution.md +"${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph" constitution.md ``` (Or `--backend codex`, or pass `-- --model ` for a specific model. See `/ralph`'s **Launching** section for the full surface.) @@ -149,7 +149,7 @@ There's no explicit review state machine. Each iteration reads the prior phase's When the user walks back into a workdir that already has artifacts: 1. **Skip ORIENT** unless the user explicitly wants to revise scope (in which case edit `constitution.md` together, no re-draft from scratch). -2. **If `constitution.md`'s `status:` is `active` and the tmux session isn't running**, re-launch the ralph loop: `.claude/skills/ralph/scripts/ralph constitution.md`. The next iteration surveys the workdir and picks up wherever the prior loop left off. +2. **If `constitution.md`'s `status:` is `active` and the tmux session isn't running**, re-launch the ralph loop: `"${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph" constitution.md`. The next iteration surveys the workdir and picks up wherever the prior loop left off. 3. **If `constitution.md`'s `status:` is `closed`**, the reproduction is at REVIEW. Run REVIEW close-out in your main session. 4. **If ORIENT substrate is incomplete** — paper-extraction errored mid-flight, or the code clone / scan didn't land — finish the missing stages in your main session before launching the loop. Both `/paper-extraction` and `/lc-from-code` are survey-first and skip done work; re-invoking against partial state is safe. diff --git a/claude/lightcone/skills/lc-from-paper/references/specify.md b/claude/lightcone/skills/lc-from-paper/references/specify.md index eba368d..6739798 100644 --- a/claude/lightcone/skills/lc-from-paper/references/specify.md +++ b/claude/lightcone/skills/lc-from-paper/references/specify.md @@ -50,7 +50,7 @@ Read the paper's section(s) covering this sub-analysis. Author: - **Options:** the chosen option plus any sibling alternatives the paper discusses. Each option carries `label:` (required) and an optional `description:`. Per the 0.0.10 grammar, options do **not** carry their own `rationale:` or `evidence:` block — the decision's `rationale:` covers the reasoning; paper-text evidence flows through `findings:` (for the paper's own quantitative claims) or via `Option.insights` back-references into `prior_insights:` (for citation-backed support). - **Option ↔ prior_insights linkage:** when the option's support derives from cited literature, list the relevant `prior_insights:` ids in `Option.insights: [, ...]`. The placeholder block under `prior_insights:` (authored in step 2 below) is the back-end of this link — LITERATURE fills in the verbatim cited-paper quote later. **Scope rules** (astra-tools ≥ 0.2.9): bare ids resolve **node-locally only** — the prior_insight must be declared in the same sub-analysis as the option. For a citation declared at an ancestor scope, use explicit upward refs: `[../id]` for the parent, `[../../id]` for the grandparent, etc. (same `../` grammar as `Input.from` and `Decision.from`). The natural shape — declare each cited paper at the sub-analysis that uses it, reference with a bare id from same-scope options — keeps everything node-local and needs no `../`. - Read `.claude/guides/decision-guide.md` (in lightcone-cli's plugin bundle) for the full definition of what counts. **Only exclude pure tooling choices** (language, library, file format) and fixed constraints. A typical sub-analysis has 2–6 decisions; if a sub-analysis has fewer than 2, revisit `work/reference/index.json` and reconsider. + Invoke `/astra` and read the **Decisions** section for the full definition of what counts. **Only exclude pure tooling choices** (language, library, file format) and fixed constraints. A typical sub-analysis has 2–6 decisions; if a sub-analysis has fewer than 2, revisit `work/reference/index.json` and reconsider. ```yaml decisions: diff --git a/claude/lightcone/skills/paper-extraction/SKILL.md b/claude/lightcone/skills/paper-extraction/SKILL.md index dd4dce3..9d678ba 100644 --- a/claude/lightcone/skills/paper-extraction/SKILL.md +++ b/claude/lightcone/skills/paper-extraction/SKILL.md @@ -132,7 +132,7 @@ Read [`references/arxiv-source.md`](references/arxiv-source.md) for Path A; [`re `scripts/extract-paper-substrate.py` does the deterministic structural pass and writes the `astra.yaml` stub: ```bash -python3 .claude/skills/paper-extraction/scripts/extract-paper-substrate.py \ +python3 "${CLAUDE_PLUGIN_ROOT}/skills/paper-extraction/scripts/extract-paper-substrate.py" \ --arxiv-id # or --doi ``` diff --git a/claude/lightcone/skills/ralph/SKILL.md b/claude/lightcone/skills/ralph/SKILL.md index f65013f..0213683 100644 --- a/claude/lightcone/skills/ralph/SKILL.md +++ b/claude/lightcone/skills/ralph/SKILL.md @@ -99,16 +99,16 @@ resolves between loops. ## Launching -The launcher is a shell script bundled with this skill. Inside a project (after `lc init` copies the bundle), its path is: +The launcher is a shell script bundled with this skill. After `lc init` registers the `lightcone` plugin (or after `claude plugin install lightcone@lightcone-cli`), Claude resolves `${CLAUDE_PLUGIN_ROOT}` to the plugin's install path; the launcher lives at: ``` -.claude/skills/ralph/scripts/ralph +${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph ``` Usage: ``` -.claude/skills/ralph/scripts/ralph [--backend claude|codex] [-- extra-flags...] +${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph [--backend claude|codex] [-- extra-flags...] ``` - `` is the constitution file. YAML frontmatter must carry `status: open` or `status: active`; the launcher refuses to start otherwise. Termination is automatic when an iteration flips `status:` to `closed`. @@ -132,13 +132,13 @@ Anything after a literal `--` separator forwards to the backend unchanged. Commo ```bash # Launch on a per-paper reproduction constitution -.claude/skills/ralph/scripts/ralph constitution.md +${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph constitution.md # Codex backend -.claude/skills/ralph/scripts/ralph constitution.md --backend codex +${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph constitution.md --backend codex # Claude backend with Chrome integration and a model override -.claude/skills/ralph/scripts/ralph constitution.md -- --chrome --model claude-opus-4-6 +${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph constitution.md -- --chrome --model claude-opus-4-6 ``` --- diff --git a/claude/lightcone/skills/ralph/references/constitution.md b/claude/lightcone/skills/ralph/references/constitution.md index 39eb28b..9b56238 100644 --- a/claude/lightcone/skills/ralph/references/constitution.md +++ b/claude/lightcone/skills/ralph/references/constitution.md @@ -61,7 +61,7 @@ Repeat until it feels solid. It does not have to be complete; open questions bel ### 4. Launch -When approved, hand to a runner. Bundled option: `.claude/skills/ralph/scripts/ralph my-constitution.md`. The runner re-reads the constitution each iteration, so refinements between iterations are normal. +When approved, hand to a runner. Bundled option: `${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph my-constitution.md`. The runner re-reads the constitution each iteration, so refinements between iterations are normal. --- diff --git a/docs/api/cli.md b/docs/api/cli.md index d0634b3..0605a55 100644 --- a/docs/api/cli.md +++ b/docs/api/cli.md @@ -1,7 +1,8 @@ # lightcone.cli.commands The Click surface. Defined in `src/lightcone/cli/commands.py`. Six -public commands: `init`, `run`, `status`, `verify`, `build`, `setup`. +public commands: `init`, `run`, `status`, `verify`, `build`, `export` +(plus `eval` when the optional `eval` extra is installed). The user-facing reference is in [CLI Overview](../cli/index.md). This page is a tour of the module internals. @@ -14,7 +15,7 @@ page is a tour of the module internals. @click.pass_context def main(ctx: click.Context) -> None: ctx.ensure_object(dict) - if ctx.invoked_subcommand in ("setup", "init", "eval"): + if ctx.invoked_subcommand in ("init", "eval"): return if not _config_path().exists(): # print friendly error, sys.exit(1) @@ -85,8 +86,48 @@ new projects look like. ## Plugin install -`_install_claude_plugin(project_dir, plugin_source, permissions)` copies -the bundled plugin into `project_dir/.claude/` (`skills`, `agents`, -`scripts`, `guides`, `templates`) and writes `.claude/settings.json` -from the chosen permission tier. Existing subdirectories are removed -before copying. +`_install_claude_plugin(project_dir, permissions)` wires up the Claude +Code plugin for a freshly scaffolded project. Two steps: + +1. **Project-scoped permissions.** Writes `project_dir/.claude/settings.json` + from the chosen permission tier (`PERMISSION_TIERS[permissions]`). This + is the only thing `lc init` puts inside the project's `.claude/`. +2. **User-scoped plugin install.** Resolves the marketplace root via + [`lightcone.cli.plugin.get_marketplace_root()`](#lightconecliplugin) and + shells out, in order, to: + ```bash + claude plugin marketplace add + claude plugin install lightcone@lightcone-cli + ``` + Both Claude CLI calls are idempotent — a second `lc init` (in any + project) is a no-op for the plugin. The plugin's skills, agents, and + hooks land user-scoped under `~/.claude/plugins/`, not per-project. + +When the `claude` CLI isn't on PATH (Codex users, …) the install step +soft-fails with a printed pointer to the manual commands; `lc init` +itself still succeeds. + +## lightcone.cli.plugin + +Marketplace discovery — defined in `src/lightcone/cli/plugin.py`. + +```python +from lightcone.cli.plugin import ( + MARKETPLACE_NAME, # "lightcone-cli" + PLUGIN_NAME, # "lightcone" + get_marketplace_root, +) +``` + +`get_marketplace_root()` returns the directory containing +`.claude-plugin/marketplace.json`, looking in two places in order: + +1. **Bundled** (installed wheel): the `lightcone/cli/` package + directory itself — populated by the `force-include` rules in + `pyproject.toml::tool.hatch.build.targets.wheel.force-include` so the + marketplace ships with the wheel. +2. **Development** (running from a checkout): the repo root, three + levels above the `lightcone/cli/` package in the src layout. + +Returns `None` if neither location has a `marketplace.json` (caller +prints a warning and skips the plugin install). diff --git a/docs/api/index.md b/docs/api/index.md index 491843b..2a5fd5d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -7,7 +7,8 @@ is a thin Click wrapper around these modules. | Module | Role | |--------|------| -| [`lightcone.cli.commands`](cli.md) | Click CLI: `init`, `run`, `build`, `status`, `verify`, `setup`. | +| [`lightcone.cli.commands`](cli.md) | Click CLI: `init`, `run`, `build`, `status`, `verify`, `export` (plus `eval` when the optional extra is installed). | +| [`lightcone.cli.plugin`](cli.md#lightconecliplugin) | Marketplace discovery for the Claude Code plugin bundled in the wheel. `get_marketplace_root()` + `MARKETPLACE_NAME` / `PLUGIN_NAME` constants. | | [`lightcone.engine.manifest`](manifest.md) | Per-output `.lightcone-manifest.json` write/read; `code_version`, `sha256_dir`. The integrity layer. | | [`lightcone.engine.snakefile`](snakefile.md) | Generate `.lightcone/Snakefile` and `snakefile-config.json` from `astra.yaml`. | | [`lightcone.engine.container`](container.md) | Runtime detection, content-addressed image tags, `wrap_recipe`. | diff --git a/docs/api/snakefile.md b/docs/api/snakefile.md index ed8f3b4..f999c57 100644 --- a/docs/api/snakefile.md +++ b/docs/api/snakefile.md @@ -22,9 +22,10 @@ Reads `astra.yaml`, resolves the analysis tree, and writes: Returns the two paths. `runtime` is one of `docker | podman | podman-hpc | none` and is used to -wrap each recipe at generation time (see -[engine.container.wrap_recipe](container.md#wrap_recipe)). Resolution is -done once here, not per rule, so all rules use a consistent runtime. +wrap each recipe at generation time (see the +[run-time wrap section in `engine.container`](container.md#run-time-wrap)). +Resolution is done once here, not per rule, so all rules use a consistent +runtime. ## `discover_universes(project_path) → list[str]` diff --git a/docs/architecture.md b/docs/architecture.md index 504fbb7..757a2c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -236,17 +236,27 @@ the qualified `.` form. ## Claude Code plugin -The plugin lives at `claude/lightcone/`. It is force-included into the -installed wheel via `pyproject.toml` so `lc init` can find it whether -you're running from source or from PyPI: +The bundle at `claude/lightcone/` is a proper Claude Code plugin: manifest at +`claude/lightcone/.claude-plugin/plugin.json`, hooks at +`claude/lightcone/hooks/hooks.json` (with command paths rooted at +`${CLAUDE_PLUGIN_ROOT}`), plus the skill / agent / script subdirectories. +The repo root carries a `.claude-plugin/marketplace.json` declaring one +plugin (`lightcone`) sourced from `./claude/lightcone`. + +Both directories are force-included into the wheel so `lc init` finds the +marketplace whether you're running from source or from PyPI: ```toml [tool.hatch.build.targets.wheel.force-include] "claude/lightcone" = "lightcone/cli/claude/lightcone" +".claude-plugin/marketplace.json" = "lightcone/cli/.claude-plugin/marketplace.json" ``` -`lightcone.cli.plugin.get_plugin_source_dir()` does the lookup: bundled -location first, dev location (relative to the repo root) second. +`lightcone.cli.plugin.get_marketplace_root()` does the lookup — it returns +the directory containing `.claude-plugin/marketplace.json` (the wheel- +installed package root, or in dev the repo root). `lc init` shells out to +`claude plugin marketplace add ` then `claude plugin install +lightcone@lightcone-cli`. Both Claude CLI calls are idempotent. ### Permission tiers @@ -254,13 +264,16 @@ location first, dev location (relative to the repo root) second. `.claude/settings.json` from the matching tier in `PERMISSION_TIERS`. `recommended` (the default) allows the agent to edit, write, and shell out, but blocks edits to dotfiles, scratch -paths, and `git push`. +paths, and `git push`. This is the only thing `lc init` writes into +the project's `.claude/` — the plugin (skills, agents, hooks) lives +user-scoped under `~/.claude/plugins/`, not duplicated per project. ### Hooks The plugin registers Claude Code hooks for venv activation, -auto-validation on save, and integrity-aware "did you forget `lc run`?" -warnings. +auto-validation on save, and surfacing materialization status at session +start. Hook scripts live at `${CLAUDE_PLUGIN_ROOT}/scripts/` and the +matchers/timeouts are declared in `claude/lightcone/hooks/hooks.json`. --- @@ -334,7 +347,7 @@ a status walker, a verify routine, the Dask cluster manager, the container-runtime layer, and a Snakemake executor plugin that submits each rule to a Dask scheduler. ---- +--- ## Configuration files diff --git a/docs/cli/init.md b/docs/cli/init.md index 045d7a5..aadbc35 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -22,12 +22,25 @@ CLAUDE.md # short note pointing future agents at the project lightcone.yaml # currently a stub: { target: local } results/ # placeholder; populated by `lc run` universes/ # placeholder; populate via `astra universe generate -n …` -.claude/ # bundled Claude Code plugin - skills/, agents/, hooks/, scripts/, templates/ - settings.json # the chosen permission tier +.claude/ + settings.json # the chosen permission tier (project-scoped) .venv/ # Python venv (skipped with --no-venv) ``` +`lc init` then shells out to the `claude` CLI (if found on PATH) to install +the `lightcone` plugin into the user's Claude Code config: + +``` +claude plugin marketplace add +claude plugin install lightcone@lightcone-cli +``` + +The plugin (skills, agents, hooks) lives user-scoped under `~/.claude/`, not +per-project. Both commands are idempotent, so subsequent `lc init` calls are +no-ops for the plugin. When `claude` isn't on PATH (Codex users, etc.), the +plugin install is skipped and the manual commands above are printed — `lc init` +still succeeds. + `lc init` refuses to run if `DIRECTORY/astra.yaml` already exists. ## Options diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md index b4d4698..24285d2 100644 --- a/docs/contributing/setup.md +++ b/docs/contributing/setup.md @@ -61,7 +61,8 @@ just build # uv build just version # current version (from git tags via hatch-vcs) ``` -The plugin (`claude/lightcone/`) is force-included into the wheel: +The plugin (`claude/lightcone/`) and the repo-root marketplace manifest +(`.claude-plugin/marketplace.json`) are both force-included into the wheel: ```toml [tool.hatch.build.targets.wheel] @@ -69,18 +70,25 @@ packages = ["src/lightcone", "src/snakemake_executor_plugin_dask"] [tool.hatch.build.targets.wheel.force-include] "claude/lightcone" = "lightcone/cli/claude/lightcone" +".claude-plugin/marketplace.json" = "lightcone/cli/.claude-plugin/marketplace.json" ``` -That layout is what `lightcone.cli.plugin.get_plugin_source_dir()` -walks — it tries the bundled location first, then the dev location -relative to the repo root. +That layout is what `lightcone.cli.plugin.get_marketplace_root()` walks — +it returns the directory containing `.claude-plugin/marketplace.json` (the +wheel-installed package root in a normal install, or the dev repo root +when running from a checkout). `lc init` shells out to +`claude plugin marketplace add ` and `claude plugin install +lightcone@lightcone-cli` against that root. ## Repo layout ```text src/lightcone/ # main namespace (PEP 420; no __init__.py at the package root) src/snakemake_executor_plugin_dask/ # Snakemake → Dask executor plugin +.claude-plugin/marketplace.json # marketplace manifest (force-included into the wheel) claude/lightcone/ # Claude Code plugin (force-included into the wheel) + .claude-plugin/plugin.json # plugin manifest + skills/ agents/ hooks/ scripts/ templates/ tests/ # pytest tree, mirrors src/ evals/ # eval task fixtures (tasks/snae/) docs/ # docs site diff --git a/docs/hpc/targets.md b/docs/hpc/targets.md index 2db4548..8e65a34 100644 --- a/docs/hpc/targets.md +++ b/docs/hpc/targets.md @@ -1,3 +1,7 @@ # Target Configuration (removed) -The per-machine target system is gone. See [`lc target`](../cli/target.md). +The old per-machine `lc target` system is gone. + +Use the project-local `.lightcone/lightcone.yaml` written by `lc init` for +lightcone-specific settings, and see [Running on a Cluster](../user/cluster.md) +for the current HPC workflow. diff --git a/docs/index.md b/docs/index.md index 3a5850b..3c2a32b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,9 @@ # lightcone-cli **lightcone-cli** is [Lightcone Research][lr]'s agentic execution layer for -[**ASTRA**][astra] (Agentic Schema for Transparent Research Analysis). +[**ASTRA**][astra] (Agentic Schema for Transparent Research Analysis). It serves as the machinery that ties an analysis `astra.yaml` specification to a tree -of materialized outputs. +of materialized outputs, whether you drive it from Claude Code, Codex, or Pi. ## Choose your path to the documentation @@ -36,13 +36,19 @@ of materialized outputs. - __lightcone-cli__ - The library that ships the `lc` CLI which handle the agent surface (skills, plugins, guardrails) as well as the workflow execution layer. Depends on [**astra-tools**][astra-tools], the SDK for working with ASTRA analysis specifications. + The library that ships the `lc` CLI plus the shared agent bundle for Claude Code, + Codex, and Pi. It owns the agent surface (skills, plugins/extensions, guardrails) + as well as the workflow execution layer. Depends on [**astra-tools**][astra-tools], + the SDK for working with ASTRA analysis specifications. - [:fontawesome-brands-github: Repository][cli]{ .md-button } + [:fontawesome-brands-github: Repository](https://github.com/LightconeResearch/lightcone-cli){ .md-button } - __astra-tools__ - The SDK for working with [**ASTRA**][astra] analysis specifications. This library provides the `astra` CLI which handles the [**ASTRA**][astra] lifecycle and validation process (schema, prior insights & findings, evidence verification helpers). + The SDK for working with [**ASTRA**][astra] analysis specifications. This library + provides the `astra` CLI which handles the [**ASTRA**][astra] lifecycle and + validation process (schema, prior insights & findings, evidence verification + helpers). [:fontawesome-brands-github: Repository][astra-tools]{ .md-button } @@ -51,4 +57,3 @@ of materialized outputs. [lr]: https://lightconeresearch.org/ [astra]: https://astra-spec.org/latest/ [astra-tools]: https://github.com/LightconeResearch/astra-tools -[cli]: https://github.com/LightconeResearch/lightcone-cli diff --git a/docs/skills/authoring.md b/docs/skills/authoring.md index 5fbfbf7..cb84cb1 100644 --- a/docs/skills/authoring.md +++ b/docs/skills/authoring.md @@ -7,7 +7,7 @@ Skills are markdown files with YAML frontmatter. Each one lives in ## File layout -```text +``` claude/lightcone/skills/ └── my-skill/ ├── SKILL.md @@ -40,9 +40,9 @@ argument-hint: "[OPTIONAL ARG] [--flag VALUE]" - `##` for phase headings; lead with a "Stage banner" line that the skill prints to the chat. -- `✓ / ○ / ✗` for status. Skip emoji elsewhere — they belong only - inside the agent's own branded banner output. -- Action prompts in blockquotes (`> "What are you trying to learn?"`). +- `✓ / ○ / ✗` for status; never emojis except inside the agent's own + branded output. +- Action prompts in bold sentences (`> "What are you trying to learn?"`). - A `## Restrictions` (or `## Hard rules`) section at the end listing invariants Claude must not break. @@ -62,7 +62,7 @@ call when a specific section is load-bearing for that skill's work. ## Spawning subagents -Use `Agent` with `subagent_type` to delegate work. The +Use `Task` with `subagent_type` to delegate work. The `lc-extractor` subagent in `agents/` is the canonical example: ```python @@ -90,9 +90,21 @@ from lightcone.eval.cli import run_cmd # or invoke `lightcone.eval.harness.run_eval(...)` directly ``` -## Installing changes into an existing project +## Picking up edits during development + +The `lightcone` plugin lives user-scoped under `~/.claude/plugins/`, installed +via `claude plugin install lightcone@lightcone-cli`. When you're working +inside a `lightcone-cli` checkout and want Claude Code to pick up your +edits, register the marketplace from the repo root (it takes precedence +over any other registration of the same name): + +```bash +claude plugin marketplace add /path/to/lightcone-cli +claude plugin install lightcone@lightcone-cli +``` + +Restart Claude Code; the plugin now loads from your checkout's +`claude/lightcone/` directly. Edits to `SKILL.md` files take effect on +the next session start. -`lc init` copies the plugin once and refuses to run a second time on -the same directory. See [Updating an existing project](../cli/update.md) -for the Python heredoc that resyncs all the plugin subdirs (`skills`, -`agents`, `scripts`, `guides`, `templates`) into an existing project. +(See [Install](../user/install.md#updating) for the upgrade-from-PyPI story.) diff --git a/docs/skills/index.md b/docs/skills/index.md index e1c352b..3ffec71 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -1,19 +1,21 @@ # Skills -Skills are Claude Code slash commands bundled in the lightcone-cli -plugin. Each shapes the agent's workflow around a recurring research -operation: scoping an analysis, wrapping existing code, reproducing -a paper. +Skills are bundled in the shared lightcone-cli agent bundle. +Claude Code and Codex expose them through the plugin manifests; Pi +installs the same bundle as a local package and aliases the top-level +`/lc-*` entry points through its extension. They give the agent a +structured, phase-by-phase workflow for the most common research +operations. -If you want to *use* these, start with -[The Agentic Workflow](../user/agent-workflow.md) in the user guide. -This page is for maintainers. +If you're a researcher trying to *use* these, the +[Agent Workflow](../user/agent-workflow.md) page in the user guide is the +friendly version. This page is for maintainers. ## Available skills -The `/lc-from-*` family is parallel in what you start from: a question, +The `/lc-from-*` family is parallel by what you start from: a question, code, or a paper. `/lc-from-paper` is the entry point of a six-skill -paper-reproduction bundle; the five siblings stand alone and are +paper-reproduction bundle; the five bundle siblings stand alone and are user-invokable directly. ### Project lifecycle @@ -22,7 +24,7 @@ user-invokable directly. |-------|---------|---------| | [lc-new](lc-new.md) | `/lc-new` | Scope a research question into an `astra.yaml`, with optional literature extraction. | | [lc-from-code](lc-from-code.md) | `/lc-from-code` | Wrap an existing codebase in ASTRA: scan, generate spec, parameterize, run. | -| [lc-from-paper](lc-from-paper.md) | `/lc-from-paper` | Reproduce a published paper in ASTRA — ORIENT-first driver that hands off to a ralph loop for the long middle. | +| [lc-from-paper](lc-from-paper.md) | `/lc-from-paper` | Reproduce a published paper in ASTRA — interview-first driver that hands off to a ralph loop for the long middle. | | [lc-feedback](lc-feedback.md) | `/lc-feedback` | File a GitHub issue against the right Lightcone repo with auto-collected context. | | [ralph](ralph.md) | `/ralph` | Author a constitution and run a ralph loop against it. Used by `lc-from-paper` for the long middle; standalone for any other long-running work. | @@ -34,7 +36,7 @@ dispatches them by role during the reproduction. | Skill | Command | Purpose | |-------|---------|---------| -| [ralph](ralph.md) | `/ralph` | Loop substrate. `lc-from-paper`'s ORIENT invokes ralph's Authoring mode to draft the per-paper constitution; the loop launcher hands off after ORIENT lands; each iteration runs ralph's Loop protocol. Also user-invokable standalone (see the Project lifecycle row above). | +| [ralph](ralph.md) | `/ralph` | Loop substrate. `lc-from-paper`'s INTERVIEW invokes ralph's Authoring mode to draft the per-paper constitution; ACQUIRE's hand-off invokes the launcher; each iteration runs ralph's Loop protocol. Also user-invokable standalone (see the Project lifecycle row above). | | [paper-extraction](paper-extraction.md) | `/paper-extraction` | Turn an arXiv ID or DOI into a standardized `work/reference/` directory: substrate, figures, tables, citations (with resolved DOIs), and a stub `astra.yaml`. | | [narrative](narrative.md) | `/narrative` | Author the `narrative:` prose and decision `rationale:` against an existing `astra.yaml`, in paper-reproduction, retrofit, or co-drafting mode. | | [figure-comparison](figure-comparison.md) | `/figure-comparison` | Build a self-contained HTML side-by-side: paper figures, tables, and numerics vs reproduced artifacts. | @@ -44,14 +46,14 @@ See the [bundle README](https://github.com/LightconeResearch/lightcone-cli/blob/ ### Reference skills (auto-primed via session-start) -Not entry points. Other skills invoke them — or Claude does, when a deeper reference would help — to load reference content into the working session. The session-start hook names both in its primer, so Claude knows they exist from the first turn. +Not direct entry points — invoked by other skills (or by Claude directly when relevant) to load reference content into the working session. The session-start hook names both in its primer, so Claude is aware they exist from the first turn. | Skill | Command | Purpose | |-------|---------|---------| | `astra` | `/astra` | Reference for the `astra.yaml` spec: structure, decisions, options, prior insights, findings, evidence, sub-analyses, narrative anchors, composition mechanics. | | `lc-cli` | `/lc-cli` | Reference for `lc` workflow: commands, the Spec-Code Invariant, status interpretation, failure diagnosis, multiverse runs, publishing via WRROC. | -These intentionally stay out of the top-level README. Researchers use the project-lifecycle skills directly; the reference skills are infrastructure. +These intentionally don't appear in the top-level README — researchers use the project-lifecycle skills directly; the reference skills are infrastructure. ## How a skill is wired @@ -68,16 +70,22 @@ argument-hint: "[DESCRIPTION]" --- ``` -The frontmatter tells Claude Code which tools the skill may invoke -and what the slash command's argument hint looks like. The body is the -prompt itself: phase definitions, rules, references to guide files, -anti-patterns. Skills bundle their own helper scripts under `scripts/` -and longer prompt fragments under `assets/` when relevant. +The frontmatter configures Claude Code: which tools the skill may +invoke, and what the slash command's argument hint looks like. The +body is the prompt — phase definitions, rules, references to guide +files, anti-patterns. The skill bundles its own helper scripts under +`scripts/` and its loop prompt template under `assets/` when relevant. ## Plugin layout -```text -claude/lightcone/ +``` +.claude-plugin/marketplace.json # marketplace manifest at repo root + +claude/lightcone/ # shared agent-bundle root +├── .claude-plugin/plugin.json # Claude plugin manifest +├── .codex-plugin/plugin.json # Codex plugin manifest +├── package.json # Pi package manifest (skills + extension) +├── extensions/lightcone.ts # Pi extension (hooks + /lc-* aliases) ├── skills/ │ ├── lc-new/{SKILL.md, references/*.md} │ ├── lc-from-code/SKILL.md @@ -90,14 +98,18 @@ claude/lightcone/ │ ├── check-sentence-by-sentence/SKILL.md │ ├── astra/SKILL.md # reference: astra.yaml spec │ └── lc-cli/SKILL.md # reference: lc workflow -├── agents/lc-extractor.md # literature subagent for /lc-new -├── templates/CLAUDE.md # the project CLAUDE.md template -└── scripts/*.sh # session lifecycle hooks (incl. session-start primer) +├── agents/lc-extractor.md # literature subagent for /lc-new +├── hooks/hooks.json # hook config (SessionStart, PostToolUse) +├── scripts/*.sh # hook handlers (venv, validate-on-save, session-start primer) +└── templates/CLAUDE.md # the project CLAUDE.md template ``` -The plugin is force-included into the wheel via +Both `.claude-plugin/marketplace.json` and `claude/lightcone/` are +force-included into the wheel via `pyproject.toml::tool.hatch.build.targets.wheel.force-include`, so -`lc init` finds it whether you're running from source or PyPI. +`lc init` can run `claude plugin marketplace add`, `codex plugin +marketplace add`, or `pi install` against the wheel's install +directory whether you're working from source or PyPI. ## Other plugin files diff --git a/docs/skills/ralph.md b/docs/skills/ralph.md index 91a911d..7f9f5cb 100644 --- a/docs/skills/ralph.md +++ b/docs/skills/ralph.md @@ -31,11 +31,13 @@ One mode applies at a time. ## Launching -After `lc init` copies the bundle into a project, the launcher lives at -`.claude/skills/ralph/scripts/ralph`: +After `lc init` (or `claude plugin install lightcone@lightcone-cli`) +registers the plugin, the launcher lives at +`${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph` — Claude resolves +`${CLAUDE_PLUGIN_ROOT}` to the user-scoped plugin install path: ```bash -.claude/skills/ralph/scripts/ralph [--backend claude|codex] [-- extra-flags...] +${CLAUDE_PLUGIN_ROOT}/skills/ralph/scripts/ralph [--backend claude|codex] [-- extra-flags...] ``` The constitution must have `status: open` or `status: active` in YAML diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index abbbd25..78614a3 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -20,24 +20,35 @@ cd r2-decision-demo ``` `lc init` is a one-shot setup. It creates a small, opinionated directory -layout and stops; it doesn't ask any questions. +layout and, when Claude Code, Codex, or Pi are on your `PATH`, installs the +shared lightcone bundle into the corresponding user-scoped agent config. -``` +## 2. What you got + +```text r2-decision-demo/ -├── astra.yaml # the spec — this is where everything lives -├── CLAUDE.md # short note for the agent (resumes context across sessions) +├── astra.yaml +├── CLAUDE.md ├── .gitignore -├── .git # initialized git repository (skip with --no-git) -├── .venv/ # Python virtual env (skip with --no-venv) -├── .claude/ # Claude Code plugin — skills, agents, hooks -├── .lightcone/ # internal scratchpad — don't edit by hand -├── Containerfile # build instructions for a local testing container -├── requirements.txt # software dependencies +├── .git +├── .venv +├── .claude/ +│ └── settings.json # Claude permissions tier only +├── .lightcone/ +│ └── lightcone.yaml +├── Containerfile +├── requirements.txt ├── universes/ +│ └── baseline.yaml ├── src/ └── results/ ``` +The shared lightcone bundle — skills, hooks, Codex manifest, and Pi extension — +installs *user-scoped* outside the project. `lc init` does not copy those assets +into `.claude/`; the only project-local Claude file is `settings.json`, which +holds the permissions tier for this repo. + The two files you'll actually look at: **`astra.yaml`** — the single source of truth for your analysis. Inputs, @@ -46,24 +57,25 @@ is downstream of this file. The boilerplate from `lc init` has one example output and an empty decisions block — enough to run `lc run` and see something materialize, but not yet a real analysis. -**`CLAUDE.md`** — a short note that tells Claude Code about the project. The -skills will update this as you go (filling in working notes, design context). -You can edit it by hand whenever you want. +**`CLAUDE.md`** — a short project note for the agent. Claude Code reads it +automatically; the same note is still useful when you're driving the project +from Codex or Pi. -## 2. Open Claude Code +## 3. Open an agent CLI ```bash -claude +claude # or: codex / pi ``` -This opens an interactive session inside the project directory. Claude Code -reads `astra.yaml` and `CLAUDE.md` so it has context from the start. +Run that command inside the project directory. `lc init` already installed +whichever agent integrations it could, so the lightcone entry points should be +available on first launch. -## 3. The slash commands +## 4. The slash commands -Inside Claude Code, the `/lc-from-*` family is organized by what you're -starting from. We'll use `/lc-new` in this guide; the others work the same -way. +Inside Claude Code, Codex, or Pi, the `/lc-from-*` family is organized by what +you're starting from. We'll use `/lc-new` in this guide; the others work the +same way. | Command | Use it when… | |---------|--------------| @@ -73,11 +85,11 @@ way. | `/lc-feedback` | Something broke and you want to file a GitHub issue without leaving the session. | These are structured entry points for common starting situations. Once inside a -project you can also just describe what you're trying to do to Claude — +project you can also just describe what you're trying to do to the agent — `astra.yaml`, `lc run`, and `lc verify` keep things tracked regardless of how you got there. -## 4. Scope the analysis with `/lc-new` +## 5. Scope the analysis with `/lc-new` Type: @@ -147,7 +159,7 @@ back a short summary table — two outputs, one decision, zero prior insights. The agent may suggest `/clear` to free up context. Take its advice. -## 5. Implement the spec +## 6. Implement the spec ```text /clear @@ -177,7 +189,7 @@ lc status Expected `lc status` output: -``` +```text Universe baseline ✓ ok r2 ✓ ok fit_plot @@ -189,7 +201,7 @@ chains. If anything fails, ask the agent to fix the concrete error and rerun. The agent commits after each successful output, so your `git log` is a clean record of the build. -## 6. Verify integrity +## 7. Verify integrity ```bash lc verify @@ -217,7 +229,7 @@ and run `lc run`, you'll get bit-identical results. ## Where to next -- [The Agentic Workflow](agent-workflow.md) — what each slash command does in +- [The Agentic Workflow](agent-workflow.md) — what each entry command does in detail. - [Running on a Cluster](cluster.md) — take the same project to SLURM. - [Troubleshooting](troubleshooting.md) — when something goes sideways. diff --git a/docs/user/index.md b/docs/user/index.md index 5d7a64e..5af5d10 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -40,7 +40,7 @@ No need to write code by hand, **you stay in charge of the scientific choices**, ```bash pip install lightcone-cli lc init my-analysis && cd my-analysis - claude + claude # then, inside Claude Code: /lc-new ``` diff --git a/docs/user/troubleshooting.md b/docs/user/troubleshooting.md index b7978b3..e6736aa 100644 --- a/docs/user/troubleshooting.md +++ b/docs/user/troubleshooting.md @@ -123,23 +123,41 @@ invoke `lc init … --permissions yolo` next time. ## I deleted `.claude/` by accident -`lc init` won't recreate it because `astra.yaml` exists. You can copy -the plugin in by hand: +The lightcone plugin (skills, agents, hooks) lives user-scoped under +`~/.claude/plugins/`, not in the project's `.claude/`. So deleting the +project-local `.claude/` only loses the project's permissions tier — the +plugin itself keeps working in every Claude Code session. + +To regenerate the permissions tier: ```bash python - <<'PY' -import shutil +import json from pathlib import Path -from lightcone.cli.plugin import get_plugin_source_dir -src = get_plugin_source_dir() -dst = Path(".claude") -for sub in ("skills", "agents", "scripts", "guides", "templates"): - s, d = src / sub, dst / sub - if d.exists(): shutil.rmtree(d) - if s.exists(): shutil.copytree(s, d) +from lightcone.cli.commands import PERMISSION_TIERS +Path(".claude").mkdir(exist_ok=True) +Path(".claude/settings.json").write_text( + json.dumps({"permissions": PERMISSION_TIERS["recommended"]}, indent=2) +) PY ``` +If the plugin itself is gone (e.g. you removed it via `claude plugin uninstall`), +re-install it from any directory: + +```bash +claude plugin install lightcone@lightcone-cli +``` + +The marketplace registration usually persists; if it doesn't, point it at the +installed wheel: + +```bash +python -c "from lightcone.cli.plugin import get_marketplace_root; print(get_marketplace_root())" \ + | xargs claude plugin marketplace add +claude plugin install lightcone@lightcone-cli +``` + ## I want to start the spec over Move `astra.yaml` aside (don't delete it — agents like having context diff --git a/pyproject.toml b/pyproject.toml index a48f4e2..b3de9ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,14 +75,23 @@ packages = [ "src/snakemake_executor_plugin_dask", ] +# The shared agent bundle lives at claude/lightcone/. The Claude marketplace +# manifest at the repo root (.claude-plugin/marketplace.json) points at it; +# the same directory also carries the Codex manifest and Pi package manifest. +# Everything ships inside the wheel so `lc init` can shell out to +# `claude plugin marketplace add `, +# `codex plugin marketplace add `, and +# `pi install ` 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 869faf8..5929a50 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -24,13 +24,22 @@ import shutil import subprocess import sys +import tomllib +from importlib.metadata import PackageNotFoundError, version from pathlib import Path import click import yaml from rich.console import Console -from lightcone.cli.plugin import get_plugin_source_dir +from lightcone.cli.plugin import ( + CODEX_MARKETPLACE_NAME, + CODEX_PLUGIN_NAME, + MARKETPLACE_NAME, + PLUGIN_NAME, + get_agent_bundle_root, + get_marketplace_root, +) console = Console() logger = logging.getLogger(__name__) @@ -166,13 +175,14 @@ def init( permissions: str, scratch_override: str | None, ) -> None: - """Scaffold a new ASTRA project with Claude Code integration. + """Scaffold a new ASTRA project with agent integration. Delegates the spec scaffold (``astra.yaml``, ``universes/baseline.yaml``, base ``.gitignore``, ``src/``) to ``astra init``, then layers on the lightcone-specific bits: ``Containerfile`` + ``requirements.txt``, - ``.lightcone/`` project state, ``.claude/`` plugin bundle, ``CLAUDE.md``, - and an optional Python venv. + ``.lightcone/`` project state, Claude permission settings, the shared + Claude/Codex/Pi agent bundle install hints, ``CLAUDE.md``, and an optional + Python venv. """ console.print(f"[cyan]{_LIGHTCONE}[/cyan]") @@ -223,10 +233,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) + # Agent integrations: write the project's Claude permission tier, then + # shell out to each agent CLI so the shared lightcone bundle (skills, + # hooks, Pi extension) lives in the user's global agent config rather than + # duplicated into every project. + _install_claude_plugin(directory, permissions) + _install_codex_plugin() + _install_pi_bundle() # Project CLAUDE.md (a stub) (directory / "CLAUDE.md").write_text(_PROJECT_CLAUDE_MD) @@ -299,7 +312,7 @@ def init( console.print("\nNext steps:") console.print(f" • Go to the newly created directory [cyan]cd {directory}[/cyan]") - console.print(" • Start [cyan]claude[/cyan]") + console.print(" • Start [cyan]claude[/cyan], [cyan]codex[/cyan], or [cyan]pi[/cyan]") console.print( " • Run [cyan]/lc-new[/cyan] to scope a new analysis, " "[cyan]/lc-from-code[/cyan] to port existing code, " @@ -370,32 +383,244 @@ 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. - 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. + 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. + + 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}" + ) + + +def _install_codex_plugin() -> None: + """Wire up the Codex plugin for this user. + + Codex does not currently expose a ``codex plugin install`` command. The + marketplace is registered through the CLI, then the enabled plugin and + plugin-hooks feature are written directly to ``~/.codex/config.toml``. + Current Codex builds also expect installed plugin files under the cache + layout, so we mirror the bundled lightcone plugin there. + """ + marketplace_root = get_marketplace_root() + bundle_root = get_agent_bundle_root() + if marketplace_root is None or bundle_root is None: + console.print( + "[yellow]⚠ Could not locate the lightcone Codex plugin marketplace " + "manifest. Codex plugin install skipped.[/yellow]" + ) + return + + plugin_ref = f"{CODEX_PLUGIN_NAME}@{CODEX_MARKETPLACE_NAME}" + + if shutil.which("codex") is None: + console.print( + "[yellow]codex CLI not found on PATH — skipping Codex plugin install.[/yellow]" + ) + console.print( + " To install manually once Codex is available, run:\n" + f" codex plugin marketplace add {marketplace_root}\n" + f" set [plugins.\"{plugin_ref}\"].enabled = true " + "and [features].plugin_hooks = true in ~/.codex/config.toml", + markup=False, + ) + return + + try: + subprocess.run( + ["codex", "plugin", "marketplace", "add", str(marketplace_root)], + check=False, + ) + _enable_codex_plugin_config(plugin_ref) + _install_codex_plugin_cache(bundle_root) + except OSError as e: + console.print( + f"[yellow]⚠ Could not invoke `codex plugin marketplace add`: {e}[/yellow]\n" + f" Install manually: codex plugin marketplace add {marketplace_root}" + ) + except Exception as e: + console.print(f"[yellow]⚠ Could not finish Codex plugin install: {e}[/yellow]") + + +def _install_pi_bundle() -> None: + """Wire up the Pi bundle for this user. + + Pi installs local packages rather than Claude/Codex-style plugins. The + shared ``claude/lightcone`` bundle now ships a ``package.json`` manifest + selecting the Pi extension and skill files, so ``lc init`` can register the + same bundle root with ``pi install``. + """ + bundle_root = get_agent_bundle_root() + if bundle_root is None: + console.print( + "[yellow]⚠ Could not locate the lightcone Pi bundle. Pi install skipped.[/yellow]" + ) + return + + if shutil.which("pi") is None: + console.print( + "[yellow]pi CLI not found on PATH — skipping Pi bundle install.[/yellow]\n" + " To install manually once Pi is available, run:\n" + f" [cyan]pi install {bundle_root}[/cyan]\n" + " This installs the bundled lightcone skills plus the Pi extension that " + "primes ASTRA status, prepends the project venv to bash commands, and " + "re-validates astra.yaml edits." + ) + return + + try: + subprocess.run(["pi", "install", str(bundle_root)], check=False) + except OSError as e: + console.print( + f"[yellow]⚠ Could not invoke `pi install`: {e}[/yellow]\n" + f" Install manually: pi install {bundle_root}" + ) + + +def _codex_config_path() -> Path: + return Path.home() / ".codex" / "config.toml" + + +def _enable_codex_plugin_config(plugin_ref: str) -> None: + """Set the two Codex TOML booleans needed for plugin hooks. + + The stdlib can read TOML but not write it. This deliberately small writer + preserves unrelated text and only manages the two sections ``lc init`` owns. + """ + config_path = _codex_config_path() + text = config_path.read_text() if config_path.exists() else "" + parsed = tomllib.loads(text) if text.strip() else {} + + if ( + parsed.get("features", {}).get("plugin_hooks") is True + and parsed.get("plugins", {}).get(plugin_ref, {}).get("enabled") is True + ): + return + + text = _set_toml_bool(text, "features", "plugin_hooks", True) + text = _set_toml_bool(text, f'plugins."{plugin_ref}"', "enabled", True) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(text) + console.print(f"[green]✓[/green] Enabled Codex plugin [cyan]{plugin_ref}[/cyan]") + + +def _set_toml_bool(text: str, section: str, key: str, value: bool) -> str: + lines = text.splitlines() + desired = "true" if value else "false" + header = f"[{section}]" + section_start = next((i for i, line in enumerate(lines) if line.strip() == header), None) + + if section_start is None: + prefix = [""] if lines and lines[-1].strip() else [] + return "\n".join([*lines, *prefix, header, f"{key} = {desired}", ""]) + + section_end = next( + (i for i in range(section_start + 1, len(lines)) if lines[i].lstrip().startswith("[")), + len(lines), + ) + for i in range(section_start + 1, section_end): + if lines[i].split("=", 1)[0].strip() == key: + lines[i] = f"{key} = {desired}" + return "\n".join([*lines, ""]) + + lines.insert(section_end, f"{key} = {desired}") + return "\n".join([*lines, ""]) + + +def _codex_plugin_cache_version() -> str: + try: + package_version = version("lightcone-cli") + except PackageNotFoundError: + return "dev" + return package_version if package_version.startswith("v") else f"v{package_version}" + + +def _install_codex_plugin_cache(plugin_dir: Path) -> None: + manifest = plugin_dir / ".codex-plugin" / "plugin.json" + if not manifest.is_file(): + raise FileNotFoundError(f"Codex plugin manifest missing at {manifest}") + + cache_root = ( + Path.home() + / ".codex" + / "plugins" + / "cache" + / CODEX_MARKETPLACE_NAME + / CODEX_PLUGIN_NAME + ) + version_dir = cache_root / _codex_plugin_cache_version() + tmp_root = cache_root.with_name(f"{cache_root.name}.tmp") + tmp_version_dir = tmp_root / version_dir.name + + shutil.rmtree(tmp_root, ignore_errors=True) + shutil.copytree(plugin_dir, tmp_version_dir, ignore=shutil.ignore_patterns(".DS_Store")) + shutil.rmtree(cache_root, ignore_errors=True) + cache_root.parent.mkdir(parents=True, exist_ok=True) + tmp_root.rename(cache_root) + console.print(f"[green]✓[/green] Installed Codex plugin cache at [cyan]{version_dir}[/cyan]") # ============================================================================= diff --git a/src/lightcone/cli/plugin.py b/src/lightcone/cli/plugin.py index 903dd26..df33094 100644 --- a/src/lightcone/cli/plugin.py +++ b/src/lightcone/cli/plugin.py @@ -1,34 +1,68 @@ -"""Plugin bundle discovery — finds the Claude Code skills/hooks shipped with lightcone-cli. +"""Agent-bundle discovery for the lightcone-cli Claude/Codex/Pi integrations. -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 Claude marketplace manifest lives at ``/.claude-plugin/marketplace.json`` +and points at the shared bundle under ``/claude/lightcone/``. Claude and +Codex install from that marketplace root; Pi installs the bundle directory +itself as a local package. This module returns the paths ``lc init`` needs +without importing the CLI command module, so both the CLI and eval harness can +reuse it without 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 + .codex-plugin/plugin.json (the plugin name). +# The install reference passed to Claude/Codex is ``PLUGIN@MARKETPLACE``. +MARKETPLACE_NAME = "lightcone-cli" +PLUGIN_NAME = "lightcone" +CODEX_MARKETPLACE_NAME = MARKETPLACE_NAME +CODEX_PLUGIN_NAME = PLUGIN_NAME +BUNDLE_RELATIVE_PATH = Path("claude") / "lightcone" -def get_plugin_source_dir() -> Path | None: - """Find the lightcone Claude plugin source directory. - Looks for the plugin files in: +def get_marketplace_root() -> Path | None: + """Find the directory containing ``.claude-plugin/marketplace.json``. - 1. Bundled location (installed package): ``lightcone/cli/claude/lightcone/`` - 2. Development location (repo): ``claude/lightcone/`` relative to repo root + The returned path is what ``claude plugin marketplace add`` and + ``codex plugin marketplace add`` register. The marketplace then points at + the shared agent bundle in ``./claude/lightcone``. + + Looks in two locations, in order: + + 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 + + +def get_agent_bundle_root() -> Path | None: + """Return the shared Claude/Codex/Pi bundle directory. + + The bundle lives under ``claude/lightcone`` in both source checkouts and + wheel installs. Claude/Codex treat it as a plugin source; Pi installs the + same directory as a local package. + """ + marketplace_root = get_marketplace_root() + if marketplace_root is None: + return None + + bundle_root = marketplace_root / BUNDLE_RELATIVE_PATH + return bundle_root if bundle_root.is_dir() else None diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f5f005..8215d06 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,6 +27,28 @@ def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return fake_home +@pytest.fixture(autouse=True) +def _no_real_agent_plugin_install(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent tests from shelling out to real agent plugin installers. + + ``lc init`` calls ``shutil.which(...)`` for Claude, Codex, and Pi to decide + whether to install the bundled agent integrations. In CI and on dev + machines that have those CLIs on PATH, the unmocked behavior would write to + the user's actual ``~/.claude/``, ``~/.codex/``, or ``~/.pi/`` config. We + default to the soft-fail branch (print hints, 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 in {"claude", "codex", "pi"}: + return None + return real_which(name, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(shutil, "which", fake_which) + + # ---- top-level ------------------------------------------------------------ @@ -106,6 +128,237 @@ 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_invokes_codex_plugin_when_available( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, _isolated_home: Path +) -> None: + calls: list[list[str]] = [] + + def _fake_run(cmd: list[str], **kwargs: object) -> MagicMock: + calls.append(list(cmd)) + return MagicMock(returncode=0) + + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/codex" if name == "codex" else None) + monkeypatch.setattr(subprocess, "run", _fake_run) + monkeypatch.setattr( + "lightcone.cli.commands._codex_plugin_cache_version", lambda: "dev" + ) + + project = tmp_path / "proj" + result = runner.invoke(main, ["init", str(project), "--no-git", "--no-venv"]) + assert result.exit_code == 0, result.output + + add_calls = [c for c in calls if c[:3] == ["codex", "plugin", "marketplace"]] + assert add_calls, f"expected `codex plugin marketplace add` invocation, got {calls}" + assert add_calls[0][3] == "add" + + config = (_isolated_home / ".codex" / "config.toml").read_text() + assert "[features]" in config + assert "plugin_hooks = true" in config + assert '[plugins."lightcone@lightcone-cli"]' in config + assert "enabled = true" in config + + cache_manifest = ( + _isolated_home + / ".codex" + / "plugins" + / "cache" + / "lightcone-cli" + / "lightcone" + / "dev" + / ".codex-plugin" + / "plugin.json" + ) + assert cache_manifest.exists() + + +def test_init_prints_codex_hint_when_codex_missing( + runner: CliRunner, tmp_path: Path +) -> None: + project = tmp_path / "proj" + result = runner.invoke(main, ["init", str(project), "--no-git", "--no-venv"]) + assert result.exit_code == 0, result.output + assert "codex CLI not found" in result.output + assert "codex plugin marketplace add" in result.output + assert '[plugins."lightcone@lightcone-cli"]' in result.output + + +def test_init_invokes_pi_install_when_available( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from lightcone.cli.plugin import get_agent_bundle_root + + calls: list[list[str]] = [] + + def _fake_run(cmd: list[str], **kwargs: object) -> MagicMock: + calls.append(list(cmd)) + return MagicMock(returncode=0) + + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/pi" if name == "pi" 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 + + bundle_root = get_agent_bundle_root() + assert bundle_root is not None, "agent bundle root not found" + assert ["pi", "install", str(bundle_root)] in calls + + +def test_init_prints_pi_hint_when_pi_missing( + runner: CliRunner, tmp_path: Path +) -> None: + project = tmp_path / "proj" + result = runner.invoke(main, ["init", str(project), "--no-git", "--no-venv"]) + assert result.exit_code == 0, result.output + assert "pi CLI not found" in result.output + assert "pi install" in result.output + assert "bundled lightcone skills" in result.output + assert "ASTRA status" in result.output + + +def test_codex_plugin_manifest_has_required_fields() -> None: + import json + + from lightcone.cli.plugin import get_marketplace_root + + marketplace_root = get_marketplace_root() + assert marketplace_root is not None, "marketplace root not found" + + manifest_path = ( + marketplace_root / "claude" / "lightcone" / ".codex-plugin" / "plugin.json" + ) + assert manifest_path.exists(), f"Codex plugin manifest not found at {manifest_path}" + + data = json.loads(manifest_path.read_text()) + assert data["name"] == "lightcone" + assert data["skills"] == "./skills/" + assert data["hooks"] == "./hooks/hooks.json" + + +def test_pi_package_manifest_lists_extension_and_skills() -> None: + import json + + from lightcone.cli.plugin import get_agent_bundle_root + + bundle_root = get_agent_bundle_root() + assert bundle_root is not None, "agent bundle root not found" + + manifest_path = bundle_root / "package.json" + assert manifest_path.exists(), f"Pi package manifest not found at {manifest_path}" + + data = json.loads(manifest_path.read_text()) + assert data["pi"]["extensions"] == ["./extensions/lightcone.ts"] + assert data["pi"]["skills"] == ["./skills/**/SKILL.md"] + assert (bundle_root / "extensions" / "lightcone.ts").exists() + + +def test_plugin_hooks_json_uses_wrapped_format() -> None: + """``hooks/hooks.json`` must use the plugin-standard format: top-level + ``"hooks"`` key wrapping the event map. + + Every working Claude Code plugin (felt, aria, …) uses this shape. The + flat format ``{ "SessionStart": [...] }`` is silently ignored by the + Claude Code runtime — hooks never fire if the wrapper is absent. + """ + import json + + from lightcone.cli.plugin import get_marketplace_root + + marketplace_root = get_marketplace_root() + assert marketplace_root is not None, "marketplace root not found" + + hooks_path = marketplace_root / "claude" / "lightcone" / "hooks" / "hooks.json" + assert hooks_path.exists(), f"hooks.json not found at {hooks_path}" + + data = json.loads(hooks_path.read_text()) + assert "hooks" in data, ( + "hooks.json must have a top-level 'hooks' key — " + "Claude Code plugins require { \"hooks\": { \"EventName\": [...] } }; " + "the flat format silently fails." + ) + assert isinstance(data["hooks"], dict), "'hooks' value must be a dict of event → matchers" + assert "SessionStart" in data["hooks"], "SessionStart hook must be present" + assert "PostToolUse" in data["hooks"], "PostToolUse hook must be present" + + def test_init_venv_falls_back_to_python_when_uv_missing( runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: