diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..24b40cd --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "recall-marketplace", + "owner": { + "name": "edheltzel" + }, + "description": "Persistent memory for AI coding agents", + "plugins": [ + { + "name": "recall", + "source": "./plugins/recall-claude", + "description": "Persistent SQLite memory and retrieval tools for Claude Code", + "category": "productivity", + "tags": ["memory", "sqlite", "mcp"] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 213889e..c7a03c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Top-level directories, by purpose (one line each — not a file enumeration): - `tests/` — `bun:test` suite mirroring source areas, plus install-lifecycle tests - `benchmarks/` — wake-up context-efficiency benchmark harness - `agent-skills/` — Agent Skills (SKILL.md, one per skill dir) installed to `~/.claude/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills` — the single `recall-*` command surface (the former `/Recall:*` slash commands, #228) -- `plugins/` — native host plugin bundles; Codex packages MCP plus generated host-adapted skills in `plugins/recall/` +- `plugins/` — native host plugin bundles, one per host: Codex in `plugins/recall/`, Claude Code in `plugins/recall-claude/`, each packaging MCP plus its own skill payload - `docs/` — user-facing published docs + ADRs (`docs/adr/`) + agent skill docs (`docs/agents/`) - `lib/` — shared bash for the install / update / uninstall lifecycle scripts - `opencode/` — OpenCode host integration (plugins / hooks / guide) @@ -131,7 +131,7 @@ Before adding code or content, search for an existing definition and extend it. - **Modify extraction**: Edit `hooks/RecallExtract.ts` (self-contained, no build step) - **Add a hook helper**: Create `hooks/lib/foo.ts` — kept standalone so hooks don't import from `src/` - **Edit lifecycle scripts**: `install.sh`, `update.sh`, and `uninstall.sh` share `lib/install-lib.sh` — put shared bash functions there, not duplicated across scripts. Validate each with `bash -n`. -- **Add an Agent Skill**: Create `agent-skills//SKILL.md` — the install/update/uninstall scripts pick it up automatically (canonical copy under `$RECALL_SHARED_SKILLS_DIR`, per-file symlinks into `~/.claude/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills`). Also add `` to `RECALL_SKILL_NAMES` in `uninstall.sh` so uninstall removes it. +- **Add an Agent Skill**: Create `agent-skills//SKILL.md` — the install/update/uninstall scripts pick it up automatically (canonical copy under `$RECALL_SHARED_SKILLS_DIR`, per-file symlinks into `~/.claude/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills`). Also add `` to `RECALL_SKILL_NAMES` in `uninstall.sh` so uninstall removes it, and regenerate the native plugin bundles with `bun run build:codex-plugin` and `bun run build:claude-plugin` (their tests fail on drift). - **Update the Claude guide**: Edit `FOR_CLAUDE.md` (installer copies it to `~/.claude/Recall_GUIDE.md`). Keep `FOR_OPENCODE.md` and `FOR_PI.md` in sync if lifecycle commands change. - **Cut a release**: See `docs/releasing.md` for the tag → GitHub release flow. @@ -175,7 +175,7 @@ Child AGENTS.md files own domain-specific local rules. Read the applicable one b - [`benchmarks/AGENTS.md`](benchmarks/AGENTS.md) — wake-up context-efficiency benchmark harness - [`docs/AGENTS.md`](docs/AGENTS.md) — user-facing published docs, ADRs, agent skill docs (never plans/specs) - [`agent-skills/AGENTS.md`](agent-skills/AGENTS.md) — `recall-*` Agent Skill definitions -- [`plugins/AGENTS.md`](plugins/AGENTS.md) — native host plugin manifests, MCP registration, and generated adapters +- [`plugins/AGENTS.md`](plugins/AGENTS.md) — per-host native plugin manifests, MCP registration, and generated skill payloads Owned at root (no child doc): lifecycle scripts (`install.sh`, `update.sh`, `uninstall.sh`) + their shared `lib/install-lib.sh`; platform guides (`FOR_CLAUDE.md`, `FOR_OPENCODE.md`, `FOR_PI.md`); `CONTEXT.md`; and `assets/` (banner + VHS demo tapes/gifs). diff --git a/README.md b/README.md index 3f03014..601a10f 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ Restart your agent (Claude Code, Pi, or OpenCode) to load the MCP server and hoo Codex uses its native plugin marketplace instead of the lifecycle installer; see [Codex Integration](docs/CODEX_INTEGRATION.md). +Claude Code can additionally install Recall as a native plugin, which takes over the nine `recall-*` skills and the `recall-memory` MCP server while the installer keeps owning the lifecycle hooks. Existing installs need one reconciliation step — see [Claude Integration](docs/CLAUDE_INTEGRATION.md). + ### First run: set your identity Recall's tiered RecallStart injects a small identity file at the top @@ -305,6 +307,7 @@ If you're an AI agent reading this repository: | What you need | Where to find it | | -------------------------------------------------------------- | ------------------------------------ | | **Using Recall from Claude Code** (MCP tools, CLI, core rules) | [`FOR_CLAUDE.md`](FOR_CLAUDE.md) | +| **Installing the Claude Code plugin** | [`docs/CLAUDE_INTEGRATION.md`](docs/CLAUDE_INTEGRATION.md) | | **Using Recall from OpenCode** | [`FOR_OPENCODE.md`](FOR_OPENCODE.md) | | **Using Recall from Pi** | [`FOR_PI.md`](FOR_PI.md) | | **Using Recall from Codex** | [`docs/CODEX_INTEGRATION.md`](docs/CODEX_INTEGRATION.md) | @@ -316,7 +319,7 @@ Recall is built around two integration surfaces: **MCP** (memory search and add, | Agent | MCP | Lifecycle hooks | Status | | ------------------------------------------------------------- | :-: | :--------------------------------------------------------: | ------------------------------------- | -| [**Claude Code**](https://claude.com/claude-code) | ✅ | ✅ Stop · SessionStart · PreCompact | **Stable** — reference implementation | +| [**Claude Code**](https://claude.com/claude-code) | ✅ | ✅ Stop · SessionStart · PreCompact | **Stable** — reference implementation; native plugin ships skills + MCP | | [**Pi**](https://pi.dev/) | ✅ | ⚠ Beta — `recall-compaction` + `recall-extract` extensions | In progress | | [**OpenCode**](https://opencode.ai/) | ✅ | ⚠ Alpha — `recall-extract` plugin | In progress | | [**Codex CLI**](https://github.com/openai/codex) | ✅ | — native plugin, explicit dump only | MCP + skills available | @@ -338,6 +341,7 @@ Have an agent you'd like to see supported? [Open an issue](https://github.com/ed | Codebase Map (local) | Interactive visual map at `.agents/atlas/artifacts/2026-06-10-recall-codebase-map.html` — generated from the codegraph index, not committed (`.agents/` is gitignored) | | [Agent Skills](docs/agent-skills.md) | `recall-*` skills for Claude Code, Pi, and omp | | [Codex Integration](docs/CODEX_INTEGRATION.md) | Native plugin install, MCP coverage, and lifecycle limits | +| [Claude Integration](docs/CLAUDE_INTEGRATION.md) | Native plugin install, plugin/installer ownership split, migration | | [Upgrading](docs/upgrading.md) | Update, backup, migration system | | [Troubleshooting](docs/troubleshooting.md) | Common issues and fixes | | [Changelog](CHANGELOG.md) | Release notes and breaking changes | diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c7c3f05..4f5676e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -8,7 +8,7 @@ User-facing published documentation: installation, CLI / MCP / agent-skill refer ## Ownership -- Reference & guides — `installation.md`, `cli-reference.md`, `mcp-tools.md`, `agent-skills.md`, `architecture.md`, `troubleshooting.md`, `releasing.md`, `upgrading.md`, `OPENCODE_INTEGRATION.md`, `PI_INTEGRATION.md`, `CODEX_INTEGRATION.md` +- Reference & guides — `installation.md`, `cli-reference.md`, `mcp-tools.md`, `agent-skills.md`, `architecture.md`, `troubleshooting.md`, `releasing.md`, `upgrading.md`, `OPENCODE_INTEGRATION.md`, `PI_INTEGRATION.md`, `CODEX_INTEGRATION.md`, `CLAUDE_INTEGRATION.md` - `adr/` — architectural decision records - `agents/` — agent skill docs (`issue-tracker.md`, `triage-labels.md`, `board-status.md`, `domain.md`, `worker-flow.md`, `dox-framework.md`) @@ -18,6 +18,7 @@ User-facing published documentation: installation, CLI / MCP / agent-skill refer - ADRs are numbered and append-only: change a decision by adding a new `NNNN-*.md`; don't rewrite a past ADR's decision. - Keep docs in sync with behavior — a command, MCP, or lifecycle change must update the matching reference (`cli-reference`, `mcp-tools`, `agent-skills`, `installation`, `upgrading`). - Host integration guides stay aligned with their root counterparts (`FOR_OPENCODE.md`, `FOR_PI.md`). Codex's canonical guide is `CODEX_INTEGRATION.md` because its native plugin is repository-distributed rather than lifecycle-installed. +- `CLAUDE_INTEGRATION.md` covers Claude's native plugin and how it divides ownership with the lifecycle installer (plugin: skills + MCP; installer: hooks). `FOR_CLAUDE.md` stays the agent-facing usage guide — keep the split, don't merge them. ## Work Guidance diff --git a/docs/CLAUDE_INTEGRATION.md b/docs/CLAUDE_INTEGRATION.md new file mode 100644 index 0000000..259f343 --- /dev/null +++ b/docs/CLAUDE_INTEGRATION.md @@ -0,0 +1,125 @@ +# Claude Code Integration + +[Back to README](../README.md) + +Recall ships two Claude Code surfaces, and they own different things: + +- The **lifecycle installer** (`install.sh`) owns hooks — `Stop`, `SessionStart`, `PreCompact` — which is how Recall captures sessions automatically and injects the tiered L0/L1 context block. +- The **native plugin** owns the nine `recall-*` skills and the `recall-memory` MCP server, packaged behind one manifest instead of user-level configuration. + +The checked-in marketplace manifest is `.claude-plugin/marketplace.json`, and the plugin bundle is `plugins/recall-claude/`. + +Running only the installer is still fully supported; the plugin is additive. + +## Install + +Install Recall first so `recall-mcp` is on `PATH`: + +```bash +bun install -g recall-memory +recall init +``` + +Then add this repository as a marketplace and install its plugin: + +```bash +claude plugin marketplace add /absolute/path/to/Recall +claude plugin install recall@recall-marketplace +``` + +The repository path is deliberate for the current checked-in marketplace. A future remote marketplace can remove that local-clone prerequisite after its distribution and update policy are defined. + +## What MCP covers + +MCP is the primary cross-host seam. + +The plugin exposes all nine Recall operations: `memory_search`, `memory_hybrid_search`, `memory_recall`, `context_for_agent`, `memory_add`, `memory_stats`, `loa_show`, `memory_dump`, and `decision_update`. + +They query and write the same SQLite store as the CLI. + +The bundled `.mcp.json` carries no `env` block, so the server resolves its database from `RECALL_DB_PATH` in Claude's environment, falling back to `~/.agents/Recall/recall.db`. Export `RECALL_DB_PATH` in your shell when the store lives elsewhere. + +## Migrating an existing install + +An existing Recall install keeps working. It also keeps its own copies of what the plugin now ships, and Claude does **not** resolve that for you: + +- **Skills.** `~/.claude/skills/recall-*` and the plugin's nine skills both load. +- **MCP.** Claude namespaces plugin components, so the plugin registers as `plugin:recall:recall-memory` while a user-scope `recall-memory` keeps its own name. Both connect, and the same nine tools are exposed twice from two processes. + +Claude collapses the two MCP entries only when they resolve to an identical command and environment. `install.sh` writes `bun run ` plus an `env` block, so a real existing install always duplicates. + +`install.sh` and `update.sh` reconcile this, and both are idempotent — run either after installing the plugin: + +```bash +./update.sh +``` + +With the plugin active they: + +1. Remove the `~/.claude/skills/recall-*` symlinks that point into `~/.agents/Recall/shared/skills/`. Only Recall-owned symlinks are removed; real files, user-authored skills, and other tools' links are left alone, and a skill directory is deleted only when it is already empty. +2. Remove the user-scope `recall-memory` MCP registration, so the plugin's is the only one left. +3. Leave hooks and canonical files untouched. + +Skill canonicals under `~/.agents/Recall/shared/skills/` are still refreshed, because Pi, omp, and `recall doctor` read them. + +**A registration pinned to a non-default database is kept, not removed.** The plugin's bundled config cannot carry your custom path, so deleting the entry would silently repoint Recall at the default file and your history would read as empty. + +Custom-database installs therefore keep both surfaces by design, and re-running `update.sh` will not change that — the decision is made from the path stored in the entry, which stays custom. Collapsing them is a deliberate manual step, because only you can confirm the environment Claude actually launches with: + +```bash +export RECALL_DB_PATH=/path/to/your/recall.db # where you launch Claude from +claude mcp list # confirm plugin:recall:recall-memory connects +claude mcp remove recall-memory -s user # then drop the duplicate +``` + +`recall doctor` reports the state under **Claude native plugin**: `PASS` when the plugin is the sole owner, `WARN` listing the duplicates when a legacy copy is still present, `INFO` when the plugin is absent or disabled. + +Uninstalling is a separate, user-owned action — `uninstall.sh` does not remove the plugin: + +```bash +claude plugin uninstall recall@recall-marketplace +./install.sh # restores the lifecycle-owned skills and MCP registration +``` + +## What the plugin does not cover + +**Lifecycle hooks are not in the bundle.** Plugin hooks *merge* with `settings.json` hooks rather than replacing them, so shipping `Stop`/`SessionStart`/`PreCompact` in the plugin would run every capture twice for anyone who also ran `install.sh` — duplicate extractions into the same database. Auto-capture, tiered L0/L1 injection, and pre-compaction flushing therefore remain lifecycle-installed. + +This is the one place where the plugin is deliberately not self-sufficient: a plugin-only user gets skills and MCP, not automatic capture. + +## Differences from the Codex package + +Named explicitly rather than assumed away, because identical `SKILL.md` bytes do not imply identical behavior across hosts: + +| | Codex (`plugins/recall/`) | Claude (`plugins/recall-claude/`) | +| --- | --- | --- | +| Manifest | `.codex-plugin/plugin.json` | `.claude-plugin/plugin.json` | +| Marketplace | `.agents/plugins/marketplace.json` | `.claude-plugin/marketplace.json` | +| Skill payload | Generated adapters with a Codex routing preamble | Byte-verbatim copies of `agent-skills/` | +| `disable-model-invocation` | Stripped; replaced by `agents/openai.yaml` because Codex does not interpret it | Kept — it is Claude's own frontmatter contract | +| MCP naming | `recall-memory` | `plugin:recall:recall-memory` (namespaced) | +| Pre-existing install to reconcile | None — Codex had no lifecycle installer | Nine skill symlinks and a user-scope MCP entry | +| Lifecycle capture | Not implemented on any surface | Implemented, but by the installer's hooks — not the plugin | + +The Claude bundle needs no routing preamble because `agent-skills/` is already authored against Claude's frontmatter. Rewriting the bodies would change behavior relative to the skills users already have, which is the opposite of a safe migration. + +Skills are copied rather than symlinked into the bundle. Claude drops symlinks that leave the plugin root when a plugin is installed from a local path, and a checkout with `core.symlinks=false` would degrade the payload silently. + +## Current boundaries + +The following are unresolved rather than guessed: + +- **End-to-end enforcement of `disable-model-invocation` for plugin-shipped skills is unverified.** The installed CLI carries the runtime enforcement for the field, and the bundle ships it unchanged, but confirming that a plugin-loaded `recall-dump` is explicit-only needs an authenticated session — and `claude plugin validate --strict` does not inspect skill frontmatter at all, so passing validation is not evidence. +- Skill *invocation* names under a plugin. `claude plugin details` lists all nine by their canonical names; the namespacing observed for MCP was not separately confirmed for skills. +- Remote marketplace publication, update, and release ownership. +- The installed plugin-cache path as a durable runtime dependency. + +None of these block the nine MCP operations or the skill surface. + +## Development verification + +`bun run build:claude-plugin` regenerates the bundle's skills from the canonical `agent-skills/` sources. + +`bun test tests/plugins/claude-plugin.test.ts` asserts one plugin identity across the manifests, that the checked-in skills are byte-identical to their canonical sources, and that no hooks ship in the bundle. + +`bun run test:e2e:claude-plugin` builds Recall, then against the **current local Claude CLI**: validates both manifests in strict mode, installs the plugin into a disposable `HOME`, asserts nine skills and the MCP server load from the bundle, seeds a legacy install and proves the migration removes both duplicates twice over without touching a user-authored skill, exercises all nine MCP tools against a disposable `RECALL_DB_PATH`, and verifies that neither the production database nor the real `~/.claude` changed. diff --git a/lib/install-lib.sh b/lib/install-lib.sh index 1ca33b2..0c40a7a 100644 --- a/lib/install-lib.sh +++ b/lib/install-lib.sh @@ -1249,11 +1249,125 @@ _recall_link_skills_to() { done } +# ── Claude Code native plugin ──────────────────────────────────────────────── +# +# Claude Code can install Recall as a native plugin (docs/CLAUDE_INTEGRATION.md). +# The plugin ships the same nine skills and registers recall-memory from its own +# bundle. Claude NAMESPACES plugin components: the plugin's server appears as +# `plugin:recall:recall-memory` and does not override a user-scope `recall-memory`. +# Both therefore load at once — the session pays for a duplicated skill surface and +# two servers exposing the same nine tools. So whenever the plugin is active the +# lifecycle scripts hand it ownership of the Claude skill surface and MCP entry. +# +# Hooks stay lifecycle-owned: plugin hooks MERGE with settings.json hooks rather +# than replacing them, so shipping them in the bundle would double every capture. +# +# `src/hosts/claude.ts` carries the same plugin id for the TypeScript side. +: "${RECALL_CLAUDE_PLUGIN_ID:=recall@recall-marketplace}" + +# True when Claude has the Recall plugin installed AND not disabled. Reads Claude's +# own state files rather than shelling out to the claude CLI, which may be absent +# and must not be spawned mid-install. +recall_claude_plugin_active() { + local installed="$CLAUDE_DIR/plugins/installed_plugins.json" + [[ -f "$installed" ]] || return 1 + command -v bun &>/dev/null || return 1 + INSTALLED_FILE="$installed" SETTINGS_FILE="$CLAUDE_DIR/settings.json" \ + PLUGIN_ID="$RECALL_CLAUDE_PLUGIN_ID" bun -e ' + const fs = require("fs"); + const read = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return null; } }; + const entry = read(process.env.INSTALLED_FILE)?.plugins?.[process.env.PLUGIN_ID]; + if (!Array.isArray(entry) || entry.length === 0) process.exit(1); + const enabled = read(process.env.SETTINGS_FILE)?.enabledPlugins?.[process.env.PLUGIN_ID]; + process.exit(enabled === false ? 1 : 0); + ' 2>/dev/null +} + +# Remove ONLY Recall-owned skill symlinks under ~/.claude/skills — links whose +# target resolves into $RECALL_SHARED_SKILLS_DIR. Real files, user-authored +# skills, and links owned by other tools are never touched, and a skill +# directory is removed only when it is already empty. Idempotent. +_recall_unlink_claude_skill_links() { + local skills_root="$CLAUDE_DIR/skills" + [[ -d "$skills_root" ]] || return 0 + [[ -d "$RECALL_SHARED_SKILLS_DIR" ]] || return 0 + + local skill_dir skill_name installed_dir f target removed=0 + for skill_dir in "$RECALL_SHARED_SKILLS_DIR"/*/; do + [[ -d "$skill_dir" ]] || continue + skill_name="$(basename "$skill_dir")" + installed_dir="$skills_root/$skill_name" + [[ -d "$installed_dir" ]] || continue + for f in "$installed_dir"/*; do + [[ -L "$f" ]] || continue + target="$(readlink "$f")" + case "$target" in + "$RECALL_SHARED_SKILLS_DIR"/*) rm -f "$f" && removed=$((removed + 1)) ;; + esac + done + rmdir "$installed_dir" 2>/dev/null || true + done + + if [[ $removed -gt 0 ]]; then + log_success "Removed $removed legacy skill symlink(s) — the Claude plugin now owns the recall-* surface" + fi +} + +# Drop the user-scope recall-memory registration in favour of the plugin's, but +# ONLY when both resolve to the same database. The plugin's bundled .mcp.json +# carries no env block, so a registration pinned to a non-default RECALL_DB_PATH +# must survive — removing it would silently repoint memory at the default file +# and the user's history would look empty. Returns 0 when the legacy entry is +# gone (or was never there), 1 when it was deliberately kept. +_recall_unregister_legacy_claude_mcp() { + local default_db="$RECALL_DIR/recall.db" + local f + + for f in "$HOME/.claude.json" "$CLAUDE_DIR/settings.json"; do + [[ -f "$f" ]] || continue + grep -q "recall-memory" "$f" || continue + if CFG_FILE="$f" DEFAULT_DB="$default_db" bun -e ' + const fs = require("fs"); + const file = process.env.CFG_FILE; + let cfg; + try { cfg = JSON.parse(fs.readFileSync(file, "utf-8")); } catch { process.exit(1); } + const entry = cfg?.mcpServers?.["recall-memory"]; + if (!entry) process.exit(0); + const pinned = entry.env?.RECALL_DB_PATH ?? entry.env?.MEM_DB_PATH; + if (pinned && pinned !== process.env.DEFAULT_DB) process.exit(1); + delete cfg.mcpServers["recall-memory"]; + if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers; + fs.writeFileSync(file, JSON.stringify(cfg, null, 2)); + ' 2>/dev/null; then + log_success "Removed duplicate recall-memory registration from $(basename "$f") — the plugin provides it" + else + log_warn "Kept recall-memory in $(basename "$f"): it pins a custom RECALL_DB_PATH the plugin cannot carry" + log_warn " Both surfaces stay registered. To collapse them, export RECALL_DB_PATH where you launch" + log_warn " Claude, confirm the plugin server resolves it, then run: claude mcp remove recall-memory -s user" + fi + done + + # Success means no user-scope registration is left to duplicate the plugin's — + # re-scanned rather than tracked in the loop, so a kept entry in either file wins. + for f in "$HOME/.claude.json" "$CLAUDE_DIR/settings.json"; do + [[ -f "$f" ]] || continue + if grep -q "recall-memory" "$f"; then + return 1 + fi + done + return 0 +} + # Claude Code skills — core platform, installed unconditionally regardless of # CLAUDE_CODE_DETECTED. Skills are the single command surface (#228); the old -# /Recall:* slash commands are gone. +# /Recall:* slash commands are gone. Canonicals are always refreshed (Pi, omp, +# and doctor read them); only the ~/.claude/skills links are conditional. recall_install_claude_skills() { _recall_copy_skill_files + if recall_claude_plugin_active; then + _recall_unlink_claude_skill_links + return 0 + fi _recall_link_skills_to "$CLAUDE_DIR/skills" } @@ -1324,8 +1438,10 @@ recall_verify_install() { fi # Agent Skills — derived from the canonical dir so we adapt to whatever - # skills ship in this release. - if [[ -d "$RECALL_SHARED_SKILLS_DIR" ]]; then + # skills ship in this release. Skipped when the native plugin owns the surface: + # the ~/.claude/skills links are removed on purpose there, so probing them + # would fail a correctly converged install. + if [[ -d "$RECALL_SHARED_SKILLS_DIR" ]] && ! recall_claude_plugin_active; then local skill_dir skill_name skillfile base for skill_dir in "$RECALL_SHARED_SKILLS_DIR"/*/; do [[ -d "$skill_dir" ]] || continue @@ -1565,6 +1681,18 @@ recall_auto_migrate() { # ── MCP registration ───────────────────────────────────────────────────────── recall_configure_mcp() { + # The native plugin registers recall-memory from its own bundle. Claude + # namespaces it, so a surviving user-scope entry is a duplicate rather than an + # override — clear it instead of re-adding one. When the legacy entry pins a + # custom database it is kept on purpose, and configuration continues below so + # that entry stays healthy. + if recall_claude_plugin_active; then + if _recall_unregister_legacy_claude_mcp; then + log_success "recall-memory MCP provided by the Claude plugin" + return 0 + fi + fi + local mem_mcp_path bun_path mem_mcp_path="$(which recall-mcp 2>/dev/null || echo "$HOME/.bun/bin/recall-mcp")" bun_path="$(which bun 2>/dev/null || echo "$HOME/.bun/bin/bun")" diff --git a/package.json b/package.json index e9dbc0a..46cffa0 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "build:codex-plugin": "bun run scripts/build-codex-plugin.ts", "test:e2e:codex-plugin": "bun run build && bun run scripts/e2e-codex-plugin.ts", "check:version": "bun run scripts/check-version.ts", - "prepublishOnly": "bun run build" + "prepublishOnly": "bun run build", + "build:claude-plugin": "bun run scripts/build-claude-plugin.ts", + "test:e2e:claude-plugin": "bun run build && bun run scripts/e2e-claude-plugin.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.3", diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index a29f1fd..4a4449a 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -4,31 +4,39 @@ ## Purpose -Native host plugin bundles distributed directly from the Recall repository. +Native host plugin bundles distributed directly from the Recall repository. One bundle per host: each host's plugin primitive is used natively rather than through a shared cross-host abstraction. ## Ownership - `recall/` — Codex native plugin manifest, MCP registration, and generated `recall-*` skill adapters +- `recall-claude/` — Claude Code native plugin manifest, MCP registration, and the nine `recall-*` skills -The repository-level Codex marketplace manifest is `.agents/plugins/marketplace.json`, owned with this bundle even though it lives outside this subtree. +The repository-level marketplace manifests are owned with their bundles even though they live outside this subtree: `.agents/plugins/marketplace.json` (Codex) and `.claude-plugin/marketplace.json` (Claude). + +Both bundles declare plugin `name: "recall"` in their own host manifest; the directory names differ only because one repository hosts both. ## Local Contracts -- `.codex-plugin/plugin.json` is the canonical Codex plugin manifest and must validate with the current Codex plugin schema. +- Each host manifest (`.codex-plugin/plugin.json`, `.claude-plugin/plugin.json`) is canonical for its host and must validate against that host's current tooling. For Claude that is `claude plugin validate --strict` — which checks the manifest only, **not** skill frontmatter. - `.mcp.json` registers the installed `recall-mcp` executable; do not embed a checkout-specific executable path. -- `skills/` is generated by `scripts/build-codex-plugin.ts` from `agent-skills/`; do not hand-edit generated adapters. -- Host-specific invocation metadata belongs in each generated skill's `agents/` directory. Behavioral parity is verified per host; matching `SKILL.md` bytes are not proof. -- Codex lifecycle auto-capture is not implemented. Do not add plugin hooks until transcript format, trust, installed paths, and config ownership have verified contracts. +- `skills/` is generated from `agent-skills/` — Codex by `scripts/build-codex-plugin.ts`, Claude by `scripts/build-claude-plugin.ts`. Never hand-edit a generated skill. +- Host-specific invocation metadata belongs in each generated skill's `agents/` directory. Behavioral parity is verified per host; matching `SKILL.md` bytes are not proof — Codex strips `disable-model-invocation` and replaces it with `agents/openai.yaml`, while Claude keeps the field because it is Claude's own contract. +- Codex adapters carry a routing preamble; the Claude bundle is byte-verbatim, because `agent-skills/` is already authored against Claude's frontmatter and rewriting it would change behavior for users migrating from the lifecycle install. +- Copy skills into a bundle; never symlink. Claude drops symlinks that leave the plugin root on local-path installs, and `core.symlinks=false` checkouts degrade the payload silently. +- **No bundle ships lifecycle hooks.** Claude merges plugin hooks with `settings.json` hooks instead of replacing them, so a bundled hook would double every capture for anyone who also ran `install.sh`. Codex has no verified transcript contract at all. Do not add plugin hooks to either bundle until that changes. +- The Claude bundle coexists with the lifecycle installer, so `lib/install-lib.sh` must keep reconciling the duplicate skill symlinks and MCP registration. `RECALL_CLAUDE_PLUGIN_ID` there and `CLAUDE_PLUGIN_ID` in `src/hosts/claude.ts` are the same id — keep them in step. ## Work Guidance -- Change canonical skill content in `agent-skills/`, then run `bun run build:codex-plugin`. -- Change Codex adaptation rules in `scripts/build-codex-plugin.ts`. +- Change canonical skill content in `agent-skills/`, then run `bun run build:codex-plugin` **and** `bun run build:claude-plugin`. +- Change host adaptation rules in the matching `scripts/build-*-plugin.ts`. +- Bump each manifest `version` with `package.json`; the bundle tests assert they agree. ## Verification -- `bun test tests/plugins/codex-plugin.test.ts` +- `bun test tests/plugins/` - `bun run test:e2e:codex-plugin` +- `bun run test:e2e:claude-plugin` ## Child DOX Index diff --git a/plugins/recall-claude/.claude-plugin/plugin.json b/plugins/recall-claude/.claude-plugin/plugin.json new file mode 100644 index 0000000..01f1f88 --- /dev/null +++ b/plugins/recall-claude/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "recall", + "displayName": "Recall", + "version": "0.9.4", + "description": "Persistent SQLite memory and retrieval tools for Claude Code", + "author": { + "name": "edheltzel", + "url": "https://github.com/edheltzel" + }, + "homepage": "https://github.com/edheltzel/Recall#readme", + "repository": "https://github.com/edheltzel/Recall", + "license": "MIT", + "keywords": ["memory", "sqlite", "mcp", "coding-agents"], + "mcpServers": "./.mcp.json" +} diff --git a/plugins/recall-claude/.mcp.json b/plugins/recall-claude/.mcp.json new file mode 100644 index 0000000..3398744 --- /dev/null +++ b/plugins/recall-claude/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "recall-memory": { + "command": "recall-mcp", + "args": [] + } + } +} diff --git a/plugins/recall-claude/skills/recall-add/SKILL.md b/plugins/recall-claude/skills/recall-add/SKILL.md new file mode 100644 index 0000000..bc09e3c --- /dev/null +++ b/plugins/recall-claude/skills/recall-add/SKILL.md @@ -0,0 +1,50 @@ +--- +name: "recall-add" +description: "Add a structured memory record to Recall — breadcrumb, decision, or learning" +--- + +Manually add a structured record to the Recall memory database. Three record types are available: breadcrumbs (context notes), decisions (architectural choices), and learnings (problem/solution pairs). Use the user's request text as the record content. + +## Usage + +```bash +# Breadcrumb — quick context note +recall add breadcrumb "" + +# Decision — architectural or process decision +recall add decision "" --why "reasoning" + +# Learning — problem and solution pair +recall add learning "" "" +``` + +**Breadcrumb options:** +- `-p ` — Project name +- `-c ` — Category: context, note, todo, reference +- `-i ` — Importance 1-10 (default: 5) + +**Decision options:** +- `-p ` — Project name +- `-w, --why ` — Why this decision was made +- `-a, --alternatives ` — Alternatives considered + +**Learning options:** +- `-p ` — Project name +- `-t, --tags ` — Comma-separated tags +- `--prevention ` — How to prevent in future + +## Examples + +```bash +# Breadcrumb with high importance +recall add breadcrumb "User prefers dark mode in all UIs" -p myproject -i 8 + +# Decision with reasoning +recall add decision "Use TypeScript over Python" --why "Type safety, team preference" -p myproject + +# Learning with prevention +recall add learning "Port conflict on 4000" "Kill process or change port" --prevention "Use dynamic port allocation" + +# Tagged learning +recall add learning "bun:sqlite uses \$param syntax" "Not :param like better-sqlite3" -t "bun,sqlite,gotcha" +``` diff --git a/plugins/recall-claude/skills/recall-doctor/SKILL.md b/plugins/recall-claude/skills/recall-doctor/SKILL.md new file mode 100644 index 0000000..9fb6e37 --- /dev/null +++ b/plugins/recall-claude/skills/recall-doctor/SKILL.md @@ -0,0 +1,16 @@ +--- +name: "recall-doctor" +description: "Run health checks on all Recall memory subsystems — database, MCP, hooks, embeddings" +--- + +Run comprehensive health checks on all Recall subsystems. Checks database connectivity, MCP server registration, hook configuration, extraction tracker state, CLI paths, and Ollama embedding service. + +Start here when troubleshooting any Recall issue. + +## Usage + +```bash +recall doctor +``` + +No arguments or options. Runs all checks and reports status for each subsystem. diff --git a/plugins/recall-claude/skills/recall-dump/SKILL.md b/plugins/recall-claude/skills/recall-dump/SKILL.md new file mode 100644 index 0000000..ee16001 --- /dev/null +++ b/plugins/recall-claude/skills/recall-dump/SKILL.md @@ -0,0 +1,40 @@ +--- +name: "recall-dump" +description: "Flush current session to Recall database and capture a Library of Alexandria entry" +disable-model-invocation: true +--- + +Dump the current conversation session into the Recall SQLite database, making all messages immediately searchable. Also creates a curated LoA (Library of Alexandria) entry with extracted wisdom. + +Run this at the end of every session or when you want to persist the current conversation mid-session. + +## Usage + +```bash +recall dump "" +``` + +**Arguments:** +- `<title>` (required): Descriptive title for this session (e.g., "Auth refactor planning", "Fixed rate limiter bug"). Use the user's argument text; if none was given, derive a short title from the session. + +**Options:** +- `-p <project>` — Tag with a project name +- `-t <tags>` — Comma-separated tags +- `-c <id>` — Continue from a previous LoA entry (creates a chain) +- `--skip-fabric` — Skip Fabric extraction (faster, import only) + +## Examples + +```bash +# Basic dump +recall dump "Debugging the webhook handler" + +# With project tag +recall dump "API redesign session" -p my-api + +# Continue a previous conversation thread +recall dump "Auth refactor part 2" -c 15 + +# Quick import without Fabric extraction +recall dump "Quick fix session" --skip-fabric +``` diff --git a/plugins/recall-claude/skills/recall-loa/SKILL.md b/plugins/recall-claude/skills/recall-loa/SKILL.md new file mode 100644 index 0000000..0736487 --- /dev/null +++ b/plugins/recall-claude/skills/recall-loa/SKILL.md @@ -0,0 +1,39 @@ +--- +name: "recall-loa" +description: "Browse and search the Library of Alexandria — curated knowledge entries with extracted wisdom" +--- + +List, view, and search Library of Alexandria (LoA) entries. LoA entries are curated knowledge captures with Fabric-extracted insights, message lineage, and project context. + +## Usage + +```bash +# List recent entries +recall loa list + +# Show full entry with Fabric extract +recall loa show <id> + +# View raw source messages +recall loa quote <id> +``` + +**List options:** + +- `-l <n>` — Max entries (default: 10) + +## Examples + +```bash +# Browse recent LoA entries +recall loa list + +# Show last 20 entries +recall loa list -l 20 + +# View full Fabric-extracted wisdom for entry #5 +recall loa show 5 + +# See the raw conversation messages behind entry #12 +recall loa quote 12 +``` diff --git a/plugins/recall-claude/skills/recall-recent/SKILL.md b/plugins/recall-claude/skills/recall-recent/SKILL.md new file mode 100644 index 0000000..f0f1d63 --- /dev/null +++ b/plugins/recall-claude/skills/recall-recent/SKILL.md @@ -0,0 +1,35 @@ +--- +name: "recall-recent" +description: "Show recent Recall memory records across all tables or a specific table" +--- + +Display the most recent records from Recall memory. Shows entries across all tables by default, or filter to a specific table. + +## Usage + +```bash +recall recent [table] +``` + +**Arguments:** +- `[table]` (optional): Table to filter — messages, decisions, learnings, breadcrumbs, or all (default: all) + +**Options:** +- `-p <project>` — Filter by project name +- `-l <n>` — Max results (default: 10) + +## Examples + +```bash +# Recent records across all tables +recall recent + +# Recent decisions only +recall recent decisions + +# Recent learnings for a specific project +recall recent learnings -p my-api + +# Last 20 breadcrumbs +recall recent breadcrumbs -l 20 +``` diff --git a/plugins/recall-claude/skills/recall-scout/SKILL.md b/plugins/recall-claude/skills/recall-scout/SKILL.md new file mode 100644 index 0000000..6790736 --- /dev/null +++ b/plugins/recall-claude/skills/recall-scout/SKILL.md @@ -0,0 +1,72 @@ +--- +name: "recall-scout" +description: "Scout an unfamiliar codebase — memory-first repo map, key paths, tests, risks, and next steps, with a strict sensitive-data boundary" +allowed-tools: Bash(git log -10 --oneline), Bash(git status -s | head), Bash(codegraph status --json) +--- + +Produce a **scout report** for the current repository: a fast, structured orientation for whoever is about to work in this codebase. An optional focus argument narrows the scout to a subsystem, path, or question (e.g. `auth`, `the extraction pipeline`, `src/db`). With no focus, scout the repo as a whole. + +This is the canonical scouting workflow; platform guides reference it and only remap tool names. Do not hand-copy these steps into `FOR_CLAUDE.md`, `FOR_PI.md`, `FOR_OPENCODE.md`, or `opencode/recall-memory.md` — those files point here and supply only their own tool-name mapping. + +## Live context + +Three bounded orienters, each well under 1 KB of output. On Claude Code the `!` prefix inlines their output here at prompt load; on hosts without the `!` mechanic, run the same three commands manually as your first step. + +- Recent commits: !`git log -10 --oneline` +- Working tree: !`git status -s | head` +- CodeGraph probe: !`codegraph status --json` + +## Workflow + +1. **Memory first.** Before reading git history or source files, search Recall for what is already known about this repo and the focus. Use keyword search for names and hybrid search for natural-language questions, scoped to **this project only**. Recall's structured decisions, learnings, and session summaries are richer than commit messages — start here, not with `git log`. +2. **CodeGraph capability ladder.** Read the probe (`codegraph status --json` — it exits 0 even on an unindexed repo) and branch on `initialized`: + - **Indexed** (`initialized: true`): check freshness — if `pendingChanges` is non-zero or `reindexRecommended` is true, run `codegraph sync -q` (cheap, safe) before querying. + - **CLI installed, repo unindexed** (`initialized: false`): **offer** indexing, don't run it — "this repo isn't indexed; `codegraph init` takes ~2 s per 200 files, writes only a self-gitignored `.codegraph/`, and `codegraph uninit` reverses it — want it?" Run `codegraph init` only on an explicit user yes; indexing is the user's decision, never auto-run it. Without a yes, continue on the backstop. + - **No CLI** (the probe errors with command-not-found): skip CodeGraph entirely and produce the full report via the grep/tree-walk backstop. + - CodeGraph is an enhancement, never a hard dependency: every report section below must be producible with zero CodeGraph. (Hosts with the CodeGraph MCP server may use its single tool, `codegraph_explore`, wherever `codegraph explore` appears — identical output; everything else on this page is CLI-only.) +3. **Repo map.** Build a high-level map: languages, entry points, top-level layout, build/test commands. With a live index, start from the **orientation bundle** — `codegraph status --json` (languages, symbol-kind profile, file/node counts; already inlined above) plus `codegraph files --max-depth 2 --no-metadata` (the repo tree; add `--filter <dir>` when the focus names an area). The bundle costs ~600 tokens and replaces a blind tree walk. Then read the declared ground truth directly — these files are small and never in the index: README, `AGENTS.md`/`CLAUDE.md`, the dependency manifest (`package.json` including its `scripts`, or `pyproject.toml` / `Cargo.toml` / `go.mod`), the compiler/tooling config (`tsconfig.json` or equivalent), and CI workflow names. Without CodeGraph, the same declared reads plus a bounded tree walk produce the map. +4. **Tech stack.** From manifests only: languages, runtime, frameworks and major libraries with their **declared** versions, build tooling, test framework, and package manager — read from the manifest's declared ranges (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`). Lockfiles stay behind the sensitive-data boundary: map their existence, never their contents, so version ground truth is the declared range, not the resolved lockfile pin. CodeGraph contributes the languages list and symbol-kind profile from `status --json`; it does not know dependencies or versions. +5. **Key paths.** Identify the handful of files that matter most for the focus — entry points, core modules, config, and the seams where changes usually land. Cite them as `path:line` where useful. With a live index, surface them with at most two narrow `codegraph explore` calls (see the query discipline below) rather than guessing from the tree: one explore answers "what calls this seam", "what does it depend on", and "trace a path between two symbols" in a single query. Fall back to grep when CodeGraph isn't available. +6. **Decisions & learnings tied to the key files.** For each key path, search Recall (keyword + hybrid, scoped to **this** project) for the decisions and learnings that reference the file or the symbols it defines — *why* a hot file is the way it is. This focuses the memory-first search from step 1 on each key path. +7. **Conventions.** How the house writes code, from three legs: **declared** — the rules files already read in step 3 (`AGENTS.md`/`CLAUDE.md`/`CONTRIBUTING.md`) plus lint/format configs (`eslint`/`prettier`/`.editorconfig`, `tsconfig` strictness); **observed** — one CodeGraph style probe over a representative module (`codegraph explore` of that module, or cheaper, `codegraph node --file <path> --symbols-only`) for naming patterns, module seams, and test-file conventions — an explore used here counts against the two-explore cap; **learned** — Recall learnings, which often encode "wrong → right" convention corrections. +8. **Tests.** Locate the test suite, the runner, and how to invoke it. Note coverage gaps relevant to the focus. +9. **Risks.** Surface what would bite someone working here — fragile areas, undocumented invariants, conventions that are easy to violate (pull these from memory on the hot files and from project rules files). +10. **Next steps.** Recommend the concrete first moves for the focus, ordered, each tied to a path or command. + +## CodeGraph query discipline + +CodeGraph replaces the expensive grep-and-read loop, but `explore` pastes verbatim source and has no output cap — discipline keeps the scout token-frugal: + +- **Narrow, named queries only.** Point `explore` at a symbol, a file, or a seam — never a vague topic. Off-target matches cost the same tokens as on-target ones. +- **Soft cap: at most two `explore` calls per scout run**, including the Conventions style probe. +- **Never re-paste explore output into the report.** The report wants a one-line "why it matters" per key path and `path:line` cites — not quoted source blocks. +- **Prefer cappable forms for edge questions:** `codegraph callers <symbol> --limit 5`, `codegraph callees <symbol> --limit 5`, `codegraph query <term> --limit 3 --kind <kind>`, `codegraph node --file <path> --symbols-only`. +- **Backstop means fallback, not second pass:** if CodeGraph answered a question, skip grep and the tree walk for that same question. +- **Symbol misses exit 0.** `query`/`node`/`callers` print "not found" and still exit 0 — check the output text for misses, don't exit-check. + +## Report format + +Output the scout report **in chat** with these sections, in order: + +- **Memory summary** — what Recall already knew (decisions, learnings, breadcrumbs, prior sessions). Say so explicitly if memory was empty. +- **Repo map** — languages, layout, build/test commands. +- **Tech stack** — languages/runtime, frameworks and major libraries with declared versions, build/test tooling, package manager (from manifests; lockfiles excluded). +- **Key paths** — the files that matter, with one line each on why (grounded in the code graph where available). +- **Decisions & learnings** — per key file, the memory tied to its code (from Recall search on the file and its symbols). Omit the section if nothing was found. +- **Conventions** — the house style: declared rules, observed patterns, and Recall-learned corrections. +- **Tests** — suite location, runner, how to run, notable gaps. +- **Risks** — what would bite someone working here. +- **Next steps** — ordered, concrete, path- or command-anchored. + +## Sensitive-data boundary (MANDATORY) + +Never read, echo, summarize, or write any of the following — if the focus would require crossing this boundary, stop and say so instead: + +- **Secrets / credentials** — `.env*`, `*.key`, `*.pem`, tokens, API keys, anything matching a credential pattern. Do not print their values even if asked to "summarize config." +- **Generated / vendored files** — `node_modules/`, `dist/`, `build/`, `.git/` internals, lockfile contents. Map their existence, never their contents. +- **The live database** — never open, query, or dump `~/.agents/Recall/recall.db` (or any production DB) directly. Use the Recall CLI and search tools, which read the database for you, scoped to this project — never raw SQL against the file. +- **Cross-project memory** — scope every Recall search to **this** repository's project. Do not surface, quote, or leak another project's memory into this report. + +## Artifacts (opt-in) + +The scout report is **chat-only by default — write nothing to disk.** Persist an artifact only when the repo endorses it: a `.agents/atlas/artifacts/` directory already exists, or the user explicitly asks for a saved report. When endorsed, write **only** to `.agents/atlas/artifacts/` as `YYYY-MM-DD-scout-<focus>.md`. Never write scout artifacts to `.agents/atlas/handoffs/` (reserved for handoffs) or anywhere else. diff --git a/plugins/recall-claude/skills/recall-search/SKILL.md b/plugins/recall-claude/skills/recall-search/SKILL.md new file mode 100644 index 0000000..e7db873 --- /dev/null +++ b/plugins/recall-claude/skills/recall-search/SKILL.md @@ -0,0 +1,46 @@ +--- +name: "recall-search" +description: "Search Recall memory using FTS5 full-text search across all tables" +--- + +Search across all Recall memory tables (messages, LoA entries, decisions, learnings, breadcrumbs) using SQLite FTS5 full-text search. Supports AND, OR, NOT operators, prefix matching, exact phrases, hard table filtering, and soft table biasing. + +## Usage + +```bash +recall search "<query>" +``` + +**Arguments:** +- `<query>` (required): Search query — use the user's argument text + +**Options:** +- `-p <project>` — Filter by project name +- `-t <table>` — Hard-filter to one table: messages, loa, decisions, learnings, breadcrumbs +- `--bias-type <table>` — Softly boost one table without filtering other matches. Same values as `-t`. +- `-l <n>` — Max results (default: 20) +- `--show-provenance` — Show Record Provenance for every result (by default only unknown provenance is flagged) + +## Examples + +```bash +# Search all memory +recall search "kubernetes deployment" + +# Search decisions only (hard filter) +recall search "database choice" -t decisions + +# Prefer decisions first, but still show matching learnings/messages/etc. +recall search "database choice" --bias-type decisions + +# Filter by project +recall search "auth middleware" -p my-api + +# Exact phrase with limit +recall search '"rate limiting strategy"' -l 5 + +# FTS5 operators +recall search "webpack OR vite" -t learnings + +# Rule of thumb: use -t when you want ONLY that table; use --bias-type when you want that table first. +``` diff --git a/plugins/recall-claude/skills/recall-stats/SKILL.md b/plugins/recall-claude/skills/recall-stats/SKILL.md new file mode 100644 index 0000000..0e6f276 --- /dev/null +++ b/plugins/recall-claude/skills/recall-stats/SKILL.md @@ -0,0 +1,14 @@ +--- +name: "recall-stats" +description: "Show Recall database statistics — record counts, database size, and table breakdown" +--- + +Display database statistics for the Recall memory system, including record counts per table, database file size, and embedding status. + +## Usage + +```bash +recall stats +``` + +No arguments or options. Shows a complete overview of the database state. diff --git a/plugins/recall-claude/skills/recall-update/SKILL.md b/plugins/recall-claude/skills/recall-update/SKILL.md new file mode 100644 index 0000000..93573cd --- /dev/null +++ b/plugins/recall-claude/skills/recall-update/SKILL.md @@ -0,0 +1,90 @@ +--- +name: "recall-update" +description: "Check for the latest Recall release on GitHub and print the exact update command (check-only — does not run update.sh)." +--- + +Check whether Recall has a newer release on GitHub. This skill is +read-only — it prints the recommended next step but NEVER runs +`update.sh` inline. Running the update while a coding agent session is +attached can corrupt in-flight hook invocations (the `recall` binary +lives in the same process tree via `bun link`). + +## What this does + +1. Reads the currently installed version via `recall --version`. +2. Fetches the latest release tag from the GitHub Releases API + (`/repos/edheltzel/Recall/releases/latest` — unauthenticated, 60 + req/hr per IP). +3. If current == latest: reports "up to date" and exits. +4. If current is behind: prints current, latest, a short excerpt of the + release notes, and the exact recipe to run the update manually: + + ``` + cd <path-to-Recall> && ./update.sh + ``` + +## Why not auto-run? + +`update.sh` pulls, rebuilds, migrates the DB, and re-registers hooks. +Rebuilding the `recall` binary mid-session can leave the running hook +scripts in a half-updated state. The safe sequence is: exit the coding +agent → `./update.sh` → restart it. + +## Rate-limit fallback + +GitHub's unauthenticated rate limit is 60 requests/hour per IP. If you +hit it, the command prints a graceful fallback pointing at the releases +page: <https://github.com/edheltzel/Recall/releases>. + +## Steps for you + +Run these steps to perform the check and produce the recipe. Prefer +running the Recall-shipped helper (`./update.sh --check`) if the source +directory is locatable — it implements all of the logic below. + +1. **Locate the source directory.** Resolve the symlink target of the + `recall` binary and strip `/dist/index.js` to get the Recall checkout: + + ```bash + readlink -f "$(which recall)" | sed 's|/dist/index.js$||' + ``` + + Call this `RECALL_SRC`. + +2. **Run the check helper.** If `$RECALL_SRC/update.sh` exists, delegate + to it — it prints the exact recipe on its own: + + ```bash + cd "$RECALL_SRC" && ./update.sh --check + ``` + + Its output already includes the "cd ... && ./update.sh" line. Relay + it to the user and stop. + +3. **Manual path** (if `update.sh` is not present on older installs): + - Capture the current version: + ```bash + recall --version + ``` + - Fetch the latest release tag + body: + ```bash + curl -sf https://api.github.com/repos/edheltzel/Recall/releases/latest + ``` + Parse JSON with `jq -r .tag_name` and `jq -r .body` (or with `bun -e` + if `jq` is not available). + - Compare versions (strip leading `v`). Present the result as: + + ``` + Current: vX.Y.Z + Latest: vA.B.C + + Release notes (excerpt): + <first ~10 lines of body> + + To apply: + cd <RECALL_SRC> && ./update.sh + ``` + +4. **Do not** run `./update.sh` on the user's behalf. Stop at printing + the recipe. The user runs it themselves after exiting the coding + agent. diff --git a/scripts/build-claude-plugin.ts b/scripts/build-claude-plugin.ts new file mode 100644 index 0000000..1f023bc --- /dev/null +++ b/scripts/build-claude-plugin.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env bun + +// Generate the Claude Code plugin's skill payload from the canonical agent-skills source. +// Generated files are checked in so Claude's installed plugin cache is complete; +// agent-skills/ remains the only authoring surface. +// +// Unlike the Codex adapters, these are byte-verbatim copies. The canonical skills are +// already authored against Claude's own frontmatter contract (`disable-model-invocation`, +// `allowed-tools`), so rewriting them would change behavior relative to the skills the +// lifecycle installer has always placed in ~/.claude/skills. Copying instead of symlinking +// keeps the bundle self-contained: Claude skips symlinks that leave the plugin root when a +// plugin is installed from a local path, and Windows checkouts without symlink support +// would degrade the payload silently. + +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const sourceRoot = join(repoRoot, 'agent-skills'); +const defaultOutputRoot = join(repoRoot, 'plugins', 'recall-claude', 'skills'); + +export function generateClaudePluginSkills(outputRoot: string = defaultOutputRoot): string[] { + mkdirSync(outputRoot, { recursive: true }); + const skillNames = readdirSync(sourceRoot) + .filter(name => existsSync(join(sourceRoot, name, 'SKILL.md'))) + .sort(); + + // Drop generated directories that no longer have a canonical source, so a renamed or + // deleted skill cannot linger in the bundle. + for (const stale of readdirSync(outputRoot).filter(name => !skillNames.includes(name))) { + rmSync(join(outputRoot, stale), { recursive: true, force: true }); + } + + for (const name of skillNames) { + const sourceDir = join(sourceRoot, name); + const outputDir = join(outputRoot, name); + mkdirSync(outputDir, { recursive: true }); + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + writeFileSync(join(outputDir, entry.name), readFileSync(join(sourceDir, entry.name))); + } + } + + const generated = readdirSync(outputRoot) + .filter(name => existsSync(join(outputRoot, name, 'SKILL.md'))) + .sort(); + if (generated.join('\n') !== skillNames.join('\n')) { + throw new Error('generated Claude skill set contains stale or missing entries'); + } + return skillNames; +} + +if (import.meta.main) { + console.log(`Generated ${generateClaudePluginSkills().length} Claude plugin skills.`); +} diff --git a/scripts/e2e-claude-plugin.ts b/scripts/e2e-claude-plugin.ts new file mode 100644 index 0000000..37f2d5c --- /dev/null +++ b/scripts/e2e-claude-plugin.ts @@ -0,0 +1,319 @@ +#!/usr/bin/env bun + +// End-to-end verification of the native Claude Code plugin against the local +// Claude CLI. Every mutation is confined to a disposable HOME and a disposable +// database; the production store is metadata-checked before and after. + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const productionDb = join(homedir(), '.agents', 'Recall', 'recall.db'); +const productionClaudeHome = join(homedir(), '.claude'); +const tempRoot = mkdtempSync(join(tmpdir(), 'recall-claude-plugin-e2e-')); +const testDb = join(tempRoot, 'recall-test.db'); +const testRecallHome = join(tempRoot, 'recall-home'); +const testHome = join(tempRoot, 'home'); +const testClaudeHome = join(testHome, '.claude'); +const testBin = join(tempRoot, 'bin'); + +interface FileMetadata { + exists: boolean; + size?: number; + mtimeMs?: number; + ino?: number; +} + +function metadata(path: string): FileMetadata { + if (!existsSync(path)) return { exists: false }; + const stat = statSync(path); + return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino }; +} + +function stringEnv(env: NodeJS.ProcessEnv): Record<string, string> { + return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)); +} + +function runClaude(args: string[], env: Record<string, string>): string { + const result = spawnSync('claude', args, { cwd: repoRoot, env, encoding: 'utf-8' }); + if (result.status !== 0) { + throw new Error(`claude ${args.join(' ')} failed (${result.status})\n${result.stdout}\n${result.stderr}`); + } + return result.stdout; +} + +/** Drive an install-lib.sh function against the sandboxed Claude/Recall homes. */ +function runInstallLib(snippet: string, env: Record<string, string>): string { + const result = spawnSync( + 'bash', + ['-c', `set -euo pipefail\nsource "${join(repoRoot, 'lib', 'install-lib.sh')}"\n${snippet}`], + { cwd: repoRoot, env, encoding: 'utf-8' }, + ); + if (result.status !== 0) { + throw new Error(`install-lib snippet failed (${result.status})\n${result.stdout}\n${result.stderr}`); + } + return result.stdout; +} + +function resultText(result: Awaited<ReturnType<Client['callTool']>>): string { + return result.content + .filter((item): item is { type: 'text'; text: string } => item.type === 'text') + .map(item => item.text) + .join('\n'); +} + +function readJson(path: string): Record<string, any> { + return JSON.parse(readFileSync(path, 'utf-8')); +} + +async function main(): Promise<void> { + const productionBefore = metadata(productionDb); + const productionClaudeBefore = metadata(join(productionClaudeHome, 'settings.json')); + + for (const dir of [testRecallHome, testHome, testClaudeHome, testBin]) mkdirSync(dir, { recursive: true }); + + if (testDb === productionDb || testDb.startsWith(dirname(productionDb) + '/')) { + throw new Error(`unsafe test database path: ${testDb}`); + } + if (testClaudeHome.startsWith(productionClaudeHome)) { + throw new Error(`unsafe test Claude home: ${testClaudeHome}`); + } + + const env = stringEnv({ + ...process.env, + HOME: testHome, + RECALL_HOME: testRecallHome, + RECALL_DIR: join(testRecallHome, 'install-root'), + CLAUDE_DIR: testClaudeHome, + RECALL_DB_PATH: testDb, + RECALL_SKIP_LEGACY_DATA_MIGRATIONS: '1', + PATH: `${testBin}:${process.env.PATH || ''}`, + }); + + console.log(`isolation.test_db=${testDb}`); + console.log(`isolation.production_db=${productionDb}`); + console.log('isolation.production_db_opened=false'); + console.log(`isolation.test_claude_home=${testClaudeHome}`); + console.log(`isolation.production_claude_home=${productionClaudeHome}`); + console.log('isolation.production_claude_home_written=false'); + + const init = spawnSync('bun', ['run', 'src/index.ts', 'init'], { cwd: repoRoot, env, encoding: 'utf-8' }); + if (init.status !== 0) throw new Error(`test DB init failed\n${init.stdout}\n${init.stderr}`); + if (!existsSync(testDb)) throw new Error('test DB was not created'); + + writeFileSync( + join(testBin, 'recall-mcp'), + `#!/bin/sh\nexec bun ${JSON.stringify(join(repoRoot, 'dist', 'mcp-server.js'))} "$@"\n`, + { mode: 0o755 }, + ); + + // ── A legacy lifecycle install, exactly as today's users have it ─────────── + const installRoot = join(testRecallHome, 'install-root'); + const canonicalSkills = join(installRoot, 'shared', 'skills'); + const skillNames = ['recall-add', 'recall-doctor', 'recall-dump', 'recall-loa', 'recall-recent', + 'recall-scout', 'recall-search', 'recall-stats', 'recall-update']; + // Seeded by the installer's own linker rather than hand-rolled symlinks, so the + // migration is tested against whatever format recall_link actually produces and + // this test cannot drift from production. + runInstallLib('_recall_copy_skill_files\n_recall_link_skills_to "$CLAUDE_DIR/skills"', env); + for (const name of skillNames) { + const link = join(testClaudeHome, 'skills', name, 'SKILL.md'); + if (!existsSync(link) || !lstatSync(link).isSymbolicLink()) { + throw new Error(`legacy seed did not create a skill symlink for ${name}`); + } + if (!existsSync(join(canonicalSkills, name, 'SKILL.md'))) { + throw new Error(`legacy seed did not create a canonical for ${name}`); + } + } + // A user-authored skill that migration must never touch. + mkdirSync(join(testClaudeHome, 'skills', 'user-owned'), { recursive: true }); + writeFileSync(join(testClaudeHome, 'skills', 'user-owned', 'SKILL.md'), '---\nname: user-owned\n---\nmine\n'); + // Shaped exactly as install-lib.sh writes it (`bun run <path>` plus an env block), + // which is what makes it a genuine duplicate: Claude only collapses a user-scope + // entry into the plugin's when the two resolve to the same command and env. + const legacyMcpEntry = { + command: 'bun', + args: ['run', join(testBin, 'recall-mcp')], + env: { RECALL_DB_PATH: join(installRoot, 'recall.db') }, + }; + writeFileSync( + join(testHome, '.claude.json'), + JSON.stringify({ mcpServers: { 'recall-memory': legacyMcpEntry } }, null, 2), + ); + console.log(`legacy.skill_links=${skillNames.length}`); + console.log('legacy.mcp_registered=true'); + + // ── Install the plugin with the live CLI ────────────────────────────────── + console.log(`claude.version=${runClaude(['--version'], env).trim()}`); + runClaude(['plugin', 'validate', join(repoRoot, 'plugins', 'recall-claude'), '--strict'], env); + runClaude(['plugin', 'validate', repoRoot, '--strict'], env); + runClaude(['plugin', 'marketplace', 'add', repoRoot], env); + runClaude(['plugin', 'install', 'recall@recall-marketplace'], env); + + const installedPlugins = readJson(join(testClaudeHome, 'plugins', 'installed_plugins.json')); + if (!Array.isArray(installedPlugins.plugins?.['recall@recall-marketplace'])) { + throw new Error(`Recall plugin not installed: ${JSON.stringify(installedPlugins)}`); + } + if (readJson(join(testClaudeHome, 'settings.json')).enabledPlugins?.['recall@recall-marketplace'] !== true) { + throw new Error('Recall plugin not enabled after install'); + } + + const details = runClaude(['plugin', 'details', 'recall'], env); + const skillCount = Number(details.match(/Skills \((\d+)\)/)?.[1]); + if (skillCount !== 9) throw new Error(`expected 9 plugin skills, got ${skillCount}\n${details}`); + if (!/MCP servers \(1\)\s+recall-memory/.test(details)) { + throw new Error(`plugin did not contribute the recall-memory MCP server\n${details}`); + } + for (const name of skillNames) { + if (!details.includes(name)) throw new Error(`plugin skill missing from inventory: ${name}`); + } + console.log('claude.plugin_installed=true'); + console.log(`claude.plugin_skills=${skillCount}`); + console.log('claude.plugin_mcp_loaded=true'); + + // Claude namespaces plugin components, so both servers load side by side until + // migration reconciles them. This is the shadowing the lifecycle scripts clear: + // nine tools exposed twice, from two processes. + const mcpBefore = spawnSync('claude', ['mcp', 'list'], { cwd: repoRoot, env, encoding: 'utf-8' }).stdout; + if (!mcpBefore.includes('plugin:recall:recall-memory')) { + throw new Error(`plugin MCP server not namespaced as expected:\n${mcpBefore}`); + } + if (!/^recall-memory:/m.test(mcpBefore)) { + throw new Error(`expected the legacy registration to duplicate the plugin's:\n${mcpBefore}`); + } + console.log('claude.duplicate_before_migration=true'); + + // ── Migration: idempotent, and only Recall-owned artifacts ──────────────── + for (const pass of [1, 2]) { + runInstallLib('recall_install_claude_skills\nrecall_configure_mcp', env); + for (const name of skillNames) { + if (existsSync(join(testClaudeHome, 'skills', name))) { + throw new Error(`pass ${pass}: legacy skill link survived migration: ${name}`); + } + } + if (!existsSync(join(testClaudeHome, 'skills', 'user-owned', 'SKILL.md'))) { + throw new Error(`pass ${pass}: migration removed a user-authored skill`); + } + if (readJson(join(testHome, '.claude.json')).mcpServers?.['recall-memory']) { + throw new Error(`pass ${pass}: duplicate recall-memory registration survived migration`); + } + if (!existsSync(join(canonicalSkills, 'recall-add', 'SKILL.md'))) { + throw new Error(`pass ${pass}: canonical skills were removed`); + } + } + console.log('migration.legacy_skill_links_removed=true'); + console.log('migration.duplicate_mcp_removed=true'); + console.log('migration.user_skill_preserved=true'); + console.log('migration.idempotent=true'); + + const mcpAfter = spawnSync('claude', ['mcp', 'list'], { cwd: repoRoot, env, encoding: 'utf-8' }).stdout; + if (/^recall-memory:/m.test(mcpAfter)) throw new Error(`legacy MCP server still registered:\n${mcpAfter}`); + if (!mcpAfter.includes('plugin:recall:recall-memory')) { + throw new Error(`plugin MCP server missing after migration:\n${mcpAfter}`); + } + console.log('migration.single_mcp_server=true'); + + // A user running Recall against a non-default database must keep their + // registration: the plugin's bundled .mcp.json carries no env block, so removing + // the pinned entry would silently repoint memory at the default file and their + // history would read as empty. + const customDb = join(tempRoot, 'custom.db'); + writeFileSync( + join(testHome, '.claude.json'), + JSON.stringify({ mcpServers: { 'recall-memory': { ...legacyMcpEntry, env: { RECALL_DB_PATH: customDb } } } }, null, 2), + ); + runInstallLib('recall_configure_mcp || true', { ...env, RECALL_DB_PATH: customDb }); + const preserved = readJson(join(testHome, '.claude.json')).mcpServers?.['recall-memory']; + if (preserved?.env?.RECALL_DB_PATH !== customDb) { + throw new Error(`migration repointed a custom database registration: ${JSON.stringify(preserved)}`); + } + console.log('migration.custom_db_registration_preserved=true'); + + // ── All nine MCP tools against the disposable database ──────────────────── + const client = new Client({ name: 'recall-claude-e2e', version: '1.0.0' }); + const transport = new StdioClientTransport({ command: 'recall-mcp', env }); + await client.connect(transport); + try { + const expectedTools = [ + 'context_for_agent', 'decision_update', 'loa_show', 'memory_add', 'memory_dump', + 'memory_hybrid_search', 'memory_recall', 'memory_search', 'memory_stats', + ]; + const listed = (await client.listTools()).tools.map(tool => tool.name).sort(); + if (JSON.stringify(listed) !== JSON.stringify(expectedTools)) { + throw new Error(`unexpected MCP tools: ${JSON.stringify(listed)}`); + } + + const add = await client.callTool({ + name: 'memory_add', + arguments: { + type: 'decision', + content: 'Package Recall on the native Claude Code plugin primitive', + detail: 'Skills and MCP ship in the bundle; lifecycle hooks stay installer-owned', + project: 'Recall-e2e', + }, + }); + if (add.isError) throw new Error(resultText(add)); + const decisionId = Number(resultText(add).match(/#(\d+)/)?.[1]); + if (!decisionId) throw new Error(`decision ID missing: ${resultText(add)}`); + + const update = await client.callTool({ name: 'decision_update', arguments: { id: decisionId, action: 'revert' } }); + if (update.isError) throw new Error(resultText(update)); + + const dump = await client.callTool({ + name: 'memory_dump', + arguments: { + title: 'Claude plugin dump e2e', + project: 'Recall-e2e', + session_id: 'claude-e2e-session', + source: 'claude', + skip_fabric: true, + messages: [ + { role: 'user', content: 'Verify the native Claude plugin against an isolated database.' }, + { role: 'assistant', content: 'Skills and MCP load from the bundle; the legacy duplicates are reconciled away.' }, + ], + }, + }); + if (dump.isError) throw new Error(resultText(dump)); + const loaId = Number(resultText(dump).match(/LoA Entry:\*\* #(\d+)/)?.[1]); + if (!loaId) throw new Error(`LoA ID missing: ${resultText(dump)}`); + + const calls: Array<[string, Record<string, unknown>]> = [ + ['loa_show', { id: loaId }], + ['memory_search', { query: 'native Claude plugin', project: 'Recall-e2e' }], + ['memory_hybrid_search', { query: 'plugin packaged memory', project: 'Recall-e2e' }], + ['memory_recall', { project: 'Recall-e2e', limit: 10 }], + ['memory_stats', {}], + ['context_for_agent', { agent_task: 'continue Claude plugin verification', project: 'Recall-e2e' }], + ]; + for (const [name, args] of calls) { + const result = await client.callTool({ name, arguments: args }); + if (result.isError) throw new Error(`${name}: ${resultText(result)}`); + } + console.log(`mcp.tools_verified=${expectedTools.length}`); + } finally { + await client.close(); + } + + const productionAfter = metadata(productionDb); + if (JSON.stringify(productionAfter) !== JSON.stringify(productionBefore)) { + throw new Error(`production DB metadata changed: before=${JSON.stringify(productionBefore)} after=${JSON.stringify(productionAfter)}`); + } + const productionClaudeAfter = metadata(join(productionClaudeHome, 'settings.json')); + if (JSON.stringify(productionClaudeAfter) !== JSON.stringify(productionClaudeBefore)) { + throw new Error(`production Claude settings changed: before=${JSON.stringify(productionClaudeBefore)} after=${JSON.stringify(productionClaudeAfter)}`); + } + console.log('isolation.production_db_unchanged=true'); + console.log('isolation.production_claude_home_unchanged=true'); + console.log('e2e.status=PASS'); +} + +try { + await main(); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 56414fd..343437a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,14 +1,14 @@ // recall doctor — health check for all memory subsystems import { existsSync, statSync, readFileSync, writeFileSync, lstatSync, readlinkSync, mkdirSync, copyFileSync, unlinkSync, symlinkSync, readdirSync, renameSync, realpathSync } from 'fs'; -import { dirname, join } from 'path'; +import { basename, dirname, join } from 'path'; import { execSync, spawnSync } from 'child_process'; import { createHash } from 'crypto'; import { homedir } from 'os'; import { getDb, getDbPath } from '../db/connection.js'; import { checkAllFts } from '../lib/repair.js'; import { VERSION } from '../version.js'; -import { claudeMcpConfigTargets, claudePaths, inspectClaudeCli } from '../hosts/claude.js'; +import { CLAUDE_PLUGIN_ID, claudeMcpConfigTargets, claudePaths, claudePluginState, inspectClaudeCli } from '../hosts/claude.js'; import type { McpConfigTarget } from '../hosts/types.js'; import { getRecallHome } from '../lib/runtime-paths.js'; @@ -504,12 +504,19 @@ function buildSymlinkProbes(): SymlinkProbe[] { // recall_copy_canonical but never reached recall_link); covering it here // lets `recall doctor --fix` repair without reinstall. (The former slash // commands migrated to these skills — #228.) - for (const { name, file } of listSkillCanonicalFiles(root)) { - probes.push({ - label: `agent skill: ${name}/${file}`, - target: join(claude.skills, name, file), - canonical: join(root, 'shared', 'skills', name, file), - }); + // + // Skipped once the native plugin is active: it ships the same nine skills from + // its own cache, and install/update deliberately remove the ~/.claude/skills + // symlinks so the surface isn't listed twice. Probing them then would report a + // converged install as broken. + if (!claudePluginState(home).active) { + for (const { name, file } of listSkillCanonicalFiles(root)) { + probes.push({ + label: `agent skill: ${name}/${file}`, + target: join(claude.skills, name, file), + canonical: join(root, 'shared', 'skills', name, file), + }); + } } return probes; @@ -537,6 +544,54 @@ export function probeSkillSurface(root: string): CheckResult { return { label, status: 'PASS', message: `${count} agent skill file(s) present` }; } +// Native Claude plugin (see docs/CLAUDE_INTEGRATION.md). Claude namespaces plugin +// components, so an active plugin and a legacy lifecycle install do not collide — +// they coexist, and the user silently pays for both: nine skills listed twice and +// two recall-memory MCP servers exposing the same nine tools. install.sh/update.sh +// reconcile that; this check reports the un-reconciled state and how to clear it. +// Exported + path-injected for unit testing, mirroring probeSkillSurface. +export function probeClaudePlugin(home: string, root: string): CheckResult { + const label = 'Claude native plugin'; + const state = claudePluginState(home); + if (!state.installed) { + return { label, status: 'INFO', message: `Not installed — lifecycle install owns skills and MCP (${CLAUDE_PLUGIN_ID})` }; + } + if (!state.active) { + return { label, status: 'INFO', message: 'Installed but disabled — lifecycle install owns skills and MCP' }; + } + + const skillsDir = claudePaths(home).skills; + const leftoverSkills = [...new Set(listSkillCanonicalFiles(root).map(entry => entry.name))] + .filter(name => existsSync(join(skillsDir, name))); + const leftoverMcp = claudeMcpConfigTargets(home) + .filter(target => { + if (!existsSync(target.path)) return false; + try { + const cfg: unknown = JSON.parse(readFileSync(target.path, 'utf-8')); + return !!(cfg as { mcpServers?: Record<string, unknown> })?.mcpServers?.['recall-memory']; + } catch { + return false; + } + }) + .map(target => basename(target.path)); + + const duplicates: string[] = []; + if (leftoverSkills.length) duplicates.push(`${leftoverSkills.length} skill(s) also in ~/.claude/skills`); + if (leftoverMcp.length) duplicates.push(`recall-memory also registered in ${leftoverMcp.join(', ')}`); + if (duplicates.length) { + return { + label, + status: 'WARN', + message: `Plugin active${state.version ? ` (v${state.version})` : ''} alongside the legacy install — ${duplicates.join('; ')}. Run ./update.sh to reconcile`, + }; + } + return { + label, + status: 'PASS', + message: `Active${state.version ? ` (v${state.version})` : ''} and sole owner of the recall-* skills and recall-memory MCP`, + }; +} + function hashFile(path: string): string | null { try { const buf = readFileSync(path); @@ -924,6 +979,7 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<void> { // when zero skill canonicals exist, so add an explicit WARN for a blanked // command surface — the sole command surface since #228. results.push(probeSkillSurface(getRecallHome())); + results.push(probeClaudePlugin(homedir(), getRecallHome())); // MCP env health (issue #28): the recall-memory registration must carry // env.RECALL_DB_PATH matching the resolved DB path, or the MCP server can diff --git a/src/hosts/claude.ts b/src/hosts/claude.ts index 5bbeaf9..0365c7b 100644 --- a/src/hosts/claude.ts +++ b/src/hosts/claude.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { execFileSync } from 'child_process'; import { join } from 'path'; import type { McpConfigTarget, NativeHostAdapter } from './types.js'; @@ -30,6 +30,51 @@ export function claudePaths(home: string): ClaudePaths { }; } +/** + * Marketplace-qualified id Claude Code keys the native plugin under. Claude namespaces + * plugin components, so the bundle's MCP server registers as `plugin:recall:recall-memory` + * and never collides with a lifecycle-installed user-scope `recall-memory`. The two simply + * coexist, which is why install/update reconcile them instead of relying on an override. + * + * `lib/install-lib.sh` carries the same id for the bash lifecycle; keep them in step. + */ +export const CLAUDE_PLUGIN_ID = 'recall@recall-marketplace'; + +export interface ClaudePluginState { + installed: boolean; + /** Installed and not disabled in settings — i.e. actually contributing skills and MCP. */ + active: boolean; + version: string | null; +} + +function readJson(path: string): Record<string, unknown> | null { + if (!existsSync(path)) return null; + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record<string, unknown>) + : null; + } catch { + return null; + } +} + +/** Read Claude's own plugin state files; never shells out to the CLI. */ +export function claudePluginState(home: string): ClaudePluginState { + const paths = claudePaths(home); + const installedPlugins = readJson(join(paths.root, 'plugins', 'installed_plugins.json')); + const entries = (installedPlugins?.plugins as Record<string, unknown> | undefined)?.[CLAUDE_PLUGIN_ID]; + const record = Array.isArray(entries) ? (entries[0] as Record<string, unknown> | undefined) : undefined; + if (!record) return { installed: false, active: false, version: null }; + + const enabledPlugins = readJson(paths.settings)?.enabledPlugins as Record<string, unknown> | undefined; + return { + installed: true, + active: enabledPlugins?.[CLAUDE_PLUGIN_ID] !== false, + version: typeof record.version === 'string' ? record.version : null, + }; +} + export function claudeMcpConfigTargets(home: string): McpConfigTarget[] { const paths = claudePaths(home); const envPath = ['mcpServers', 'recall-memory', 'env']; diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c2c431a..fb5478c 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -22,6 +22,7 @@ Automated coverage for the CLI, MCP server, hooks, data layer, libraries, instal - Mirror the source layout: a test for `src/commands/foo.ts` goes in `tests/commands/`; a hook test in `tests/hooks/`. - `scripts/e2e-codex-plugin.ts` owns the isolated current-CLI verification for the native Codex marketplace, plugin install, and all nine MCP tools. +- `scripts/e2e-claude-plugin.ts` does the same for Claude, and additionally seeds a legacy lifecycle install to prove the migration removes the duplicate skill symlinks and MCP registration idempotently. It must isolate the Claude home too (`HOME` + `CLAUDE_DIR`), not just the database, and assert both were left unchanged. ## Verification diff --git a/tests/plugins/claude-plugin.test.ts b/tests/plugins/claude-plugin.test.ts new file mode 100644 index 0000000..ce376e7 --- /dev/null +++ b/tests/plugins/claude-plugin.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { generateClaudePluginSkills, repoRoot } from '../../scripts/build-claude-plugin'; +import { CLAUDE_PLUGIN_ID, claudePluginState } from '../../src/hosts/claude'; + +const bundleRoot = join(repoRoot, 'plugins/recall-claude'); + +let tempDir = ''; + +afterEach(() => { + if (tempDir && existsSync(tempDir)) rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('Claude native plugin package', () => { + test('manifest, MCP config, and marketplace use one lowercase plugin identity', () => { + const manifest = JSON.parse(readFileSync(join(bundleRoot, '.claude-plugin/plugin.json'), 'utf-8')); + const mcp = JSON.parse(readFileSync(join(bundleRoot, '.mcp.json'), 'utf-8')); + const marketplace = JSON.parse(readFileSync(join(repoRoot, '.claude-plugin/marketplace.json'), 'utf-8')); + const packageJson = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf-8')); + + expect(manifest.name).toBe('recall'); + expect(manifest.version).toBe(packageJson.version); + expect(manifest.mcpServers).toBe('./.mcp.json'); + expect(mcp.mcpServers['recall-memory']).toEqual({ command: 'recall-mcp', args: [] }); + + expect(marketplace.name).toBe('recall-marketplace'); + expect(marketplace.owner.name).toBeTruthy(); + expect(marketplace.plugins).toHaveLength(1); + expect(marketplace.plugins[0].name).toBe('recall'); + expect(marketplace.plugins[0].source).toBe('./plugins/recall-claude'); + expect(`${marketplace.plugins[0].name}@${marketplace.name}`).toBe(CLAUDE_PLUGIN_ID); + }); + + test('the bundle ships no hooks — lifecycle capture stays installer-owned', () => { + // Plugin hooks merge with settings.json hooks rather than replacing them, so a + // bundled hook would double every capture for users who also ran install.sh. + expect(existsSync(join(bundleRoot, 'hooks'))).toBe(false); + const manifest = JSON.parse(readFileSync(join(bundleRoot, '.claude-plugin/plugin.json'), 'utf-8')); + expect(manifest.hooks).toBeUndefined(); + }); + + test('checked-in skills are byte-verbatim copies of the canonical sources', () => { + tempDir = mkdtempSync(join(tmpdir(), 'recall-claude-skills-')); + const names = generateClaudePluginSkills(tempDir); + expect(names).toHaveLength(9); + for (const name of names) { + const canonical = readFileSync(join(repoRoot, 'agent-skills', name, 'SKILL.md'), 'utf-8'); + const generated = readFileSync(join(tempDir, name, 'SKILL.md'), 'utf-8'); + const checkedIn = readFileSync(join(bundleRoot, 'skills', name, 'SKILL.md'), 'utf-8'); + expect(generated).toBe(canonical); + expect(checkedIn).toBe(canonical); + } + expect(readdirSync(join(bundleRoot, 'skills')).sort()).toEqual(names); + }); + + test('Claude-only frontmatter survives into the bundle', () => { + // The reverse of the Codex adaptation: Codex cannot read disable-model-invocation + // and needed agents/openai.yaml, so the Codex adapters strip it. Claude's own + // loader consumes the field, so the bundle must keep it. + expect(readFileSync(join(bundleRoot, 'skills/recall-dump/SKILL.md'), 'utf-8')) + .toContain('disable-model-invocation: true'); + expect(readFileSync(join(bundleRoot, 'skills/recall-scout/SKILL.md'), 'utf-8')) + .toContain('allowed-tools:'); + expect(existsSync(join(bundleRoot, 'skills/recall-dump/agents/openai.yaml'))).toBe(false); + }); + + test('plugin state reads Claude\'s own files and respects an explicit disable', () => { + tempDir = mkdtempSync(join(tmpdir(), 'recall-claude-state-')); + const claudeDir = join(tempDir, '.claude'); + mkdirSync(join(claudeDir, 'plugins'), { recursive: true }); + + expect(claudePluginState(tempDir)).toEqual({ installed: false, active: false, version: null }); + + writeFileSync( + join(claudeDir, 'plugins', 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: { [CLAUDE_PLUGIN_ID]: [{ scope: 'user', version: '0.9.4' }] } }), + ); + expect(claudePluginState(tempDir)).toEqual({ installed: true, active: true, version: '0.9.4' }); + + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify({ enabledPlugins: { [CLAUDE_PLUGIN_ID]: false } })); + expect(claudePluginState(tempDir)).toEqual({ installed: true, active: false, version: '0.9.4' }); + }); +});