From de4106469e6e6f7339e68685990cf6d91e699c23 Mon Sep 17 00:00:00 2001 From: Ed Heltzel <402910+edheltzel@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:20:34 -0400 Subject: [PATCH] feat: package Recall as a native Claude Code plugin Adds `.claude-plugin/marketplace.json` and the `plugins/recall-claude/` bundle, so Claude Code installs the nine `recall-*` skills and the `recall-memory` MCP server from one manifest instead of user-level configuration. Both manifests pass `claude plugin validate --strict` against Claude Code 2.1.217. Reuses the seams from the Codex package (#245) rather than re-inventing them: same bundle shape, same generate-then-check-in skill payload, same isolated e2e contract. Skills are byte-verbatim copies of `agent-skills/` because those sources are already authored against Claude's own frontmatter; rewriting them would change behavior for users migrating off the lifecycle install. Hooks are deliberately not in the bundle. Claude merges plugin hooks with settings.json hooks rather than replacing them, so shipping lifecycle hooks would double every capture for anyone who also ran install.sh. Auto-capture stays installer-owned. Migration matters because Claude namespaces plugin components: the plugin registers as `plugin:recall:recall-memory` and does NOT override a user-scope `recall-memory`. Claude collapses the two only when they resolve to an identical command and env, and install.sh writes `bun run ` plus an env block, so every real existing install ends up exposing the same nine tools twice from two processes, plus a doubled skill surface. install.sh and update.sh now reconcile that idempotently, removing only Recall-owned symlinks and registrations. A registration pinned to a non-default database is kept, not removed, so migration can never silently repoint memory at the default file. `recall doctor` reports the plugin state and flags an un-reconciled install, and its skill-symlink probes stand down once the plugin owns that surface so a converged install no longer reports as broken. Verified with `bun run test:e2e:claude-plugin`, which installs the plugin into a disposable HOME with a disposable RECALL_DB_PATH, seeds a legacy install, proves the duplicates are removed twice over without touching a user-authored skill, exercises all nine MCP tools, and asserts the production database and the real ~/.claude were unchanged. --- .claude-plugin/marketplace.json | 16 + AGENTS.md | 6 +- README.md | 6 +- docs/AGENTS.md | 3 +- docs/CLAUDE_INTEGRATION.md | 125 +++++++ lib/install-lib.sh | 134 +++++++- package.json | 4 +- plugins/AGENTS.md | 26 +- .../recall-claude/.claude-plugin/plugin.json | 16 + plugins/recall-claude/.mcp.json | 8 + .../recall-claude/skills/recall-add/SKILL.md | 50 +++ .../skills/recall-doctor/SKILL.md | 16 + .../recall-claude/skills/recall-dump/SKILL.md | 40 +++ .../recall-claude/skills/recall-loa/SKILL.md | 39 +++ .../skills/recall-recent/SKILL.md | 35 ++ .../skills/recall-scout/SKILL.md | 72 ++++ .../skills/recall-search/SKILL.md | 46 +++ .../skills/recall-stats/SKILL.md | 14 + .../skills/recall-update/SKILL.md | 90 +++++ scripts/build-claude-plugin.ts | 56 +++ scripts/e2e-claude-plugin.ts | 319 ++++++++++++++++++ src/commands/doctor.ts | 72 +++- src/hosts/claude.ts | 47 ++- tests/AGENTS.md | 1 + tests/plugins/claude-plugin.test.ts | 85 +++++ 25 files changed, 1299 insertions(+), 27 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 docs/CLAUDE_INTEGRATION.md create mode 100644 plugins/recall-claude/.claude-plugin/plugin.json create mode 100644 plugins/recall-claude/.mcp.json create mode 100644 plugins/recall-claude/skills/recall-add/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-doctor/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-dump/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-loa/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-recent/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-scout/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-search/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-stats/SKILL.md create mode 100644 plugins/recall-claude/skills/recall-update/SKILL.md create mode 100644 scripts/build-claude-plugin.ts create mode 100644 scripts/e2e-claude-plugin.ts create mode 100644 tests/plugins/claude-plugin.test.ts 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' }); + }); +});