diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 96aedf8..b11cbe9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,10 +4,9 @@ Technical reference for how `claude-smart` wires Claude Code's lifecycle hooks t ## Core components -1. **6 lifecycle hooks** (`plugin/hooks/hooks.json`) +1. **5 lifecycle hooks** (`plugin/hooks/hooks.json`) - `SessionStart` — applies claude-smart/reflexio defaults and starts supporting services; it does not retrieve memory. - `UserPromptSubmit` — buffers each user turn, heuristically flags corrections, and searches reflexio with the prompt text to inject matching preference/skill hits as `additionalContext`. - - `PreToolUse` — searches reflexio keyed on the first line of the tool-call text (Bash command, Edit `new_string`, etc.) and injects top matches as `additionalContext`. - `PostToolUse` — records tool invocations for later extraction. - `Stop` — finalizes the assistant turn from the transcript, publishes to reflexio. - `SessionEnd` — flushes the remaining buffer with `force_extraction=True`. @@ -30,9 +29,12 @@ Claude Code session └────────────┬────────────┘ │ ▼ -Next user prompt / tool call → query-aware search for project-specific skills, - shared skills, and preferences - → additionalContext injected with matching hits +Next user prompt → query-aware search for project-specific skills, + shared skills, and preferences + → additionalContext injected with matching hits + +Mid-task model call → search_learnings MCP tool with a rewritten query + cwd + → markdown results returned directly to the model ``` ## Mapping to reflexio @@ -53,7 +55,7 @@ The adapter method names still mirror Reflexio's wire fields (`user_profiles`, ` ### Per-session injection dedup -Hook searches (UserPromptSubmit and PreToolUse) fire many times per session, so +Hook searches (UserPromptSubmit) fire many times per session, so `search_all` passes the Claude Code `session_id` to reflexio's `/api/search`. The server remembers which rules it already returned to that session and skips them on later searches, backfilling next-best matches instead — a turn with ten @@ -61,7 +63,16 @@ tool calls injects each rule once, not ten times. The seen-state lives in-memory on the backend (lost on restart, which only means an occasional re-injection). Known limitation: after Claude Code compacts a conversation, the `session_id` stays the same, so rules that fell out of the model's context are -not re-injected for the remainder of that session. +not re-injected by the hook for the remainder of that session. The +`search_learnings` MCP tool deliberately passes no session id, so an explicit +model search can return the best matches even if the hook injected them earlier. + +For the same reason, `search_learnings` results are rendered *without* citation +ids or the citation instruction (`context_format.render_learnings_plain`): the +MCP server has no `session_id`, so its hits never enter the per-session +citation registry and any rank-id marker the model emitted could not be +resolved by the Stop hook. MCP-surfaced learnings link to the dashboard via +stable real-id routes instead, and are simply not citation-tracked. ## Extraction signals diff --git a/bin/claude-smart.js b/bin/claude-smart.js index 770d437..1c1cac0 100755 --- a/bin/claude-smart.js +++ b/bin/claude-smart.js @@ -62,6 +62,7 @@ const SUPPORTED_HOSTS = [HOST_CLAUDE_CODE, HOST_CODEX, HOST_OPENCODE]; const DEFAULT_CLAUDE_SMART_HOST = HOST_CLAUDE_CODE; const REFLEXIO_DIR = join(homedir(), ".reflexio"); const CLAUDE_SMART_STATE_DIR = join(homedir(), ".claude-smart"); +const CLAUDE_SMART_INSTALL_LOG_PATH = join(CLAUDE_SMART_STATE_DIR, "install.log"); const INSTALL_FAILURE_MARKER = join(CLAUDE_SMART_STATE_DIR, "install-failed"); const OPENCODE_LOCAL_PACKAGE_DIR = join(CLAUDE_SMART_STATE_DIR, "opencode", "claude-smart"); const OPENCODE_PACKAGE_LOCK_TIMEOUT_MS = 120_000; @@ -84,6 +85,14 @@ const CODEX_PLUGIN_CACHE_DIR = join( CODEX_MARKETPLACE_NAME, "claude-smart", ); +const CLAUDE_CODE_PLUGIN_CACHE_DIR = join( + homedir(), + ".claude", + "plugins", + "cache", + CODEX_MARKETPLACE_NAME, + "claude-smart", +); const LOCAL_DATA_NOTICE = [ "Local data was kept so reinstalling claude-smart can reuse your learned rules, sessions, logs, and local Reflexio data.", "Kept folders:", @@ -115,6 +124,8 @@ const COPYTREE_IGNORE_NAMES = new Set([ ".git", "node_modules", ".next", + ".coverage", + "htmlcov", ]); const LOCAL_DEFAULT_ENV_ENTRIES = [ [ @@ -146,6 +157,18 @@ function shouldCopyPath(src) { return true; } +function appendInstallLog(message, fields = {}) { + mkdirSync(CLAUDE_SMART_STATE_DIR, { recursive: true }); + const payload = { + ts: new Date().toISOString(), + message, + ...fields, + }; + writeFileSync(CLAUDE_SMART_INSTALL_LOG_PATH, JSON.stringify(payload) + "\n", { + flag: "a", + }); +} + function runClaude(args, { spinnerLabel } = {}) { const useSpinner = Boolean(spinnerLabel) && process.stdout.isTTY && !process.env.CI; return new Promise((resolve) => { @@ -709,7 +732,7 @@ function opencodePrerequisiteError() { } function findClaudeCodePluginRoot() { - const cacheRoot = join(homedir(), ".claude", "plugins", "cache", CODEX_MARKETPLACE_NAME, "claude-smart"); + const cacheRoot = CLAUDE_CODE_PLUGIN_CACHE_DIR; const candidates = []; try { for (const entry of readdirSync(cacheRoot, { withFileTypes: true })) { @@ -753,6 +776,43 @@ function findClaudeCodePluginRoot() { return null; } +function claudeCodePluginVersion(pluginRoot) { + try { + const manifest = JSON.parse( + readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"), + ); + return typeof manifest.version === "string" && manifest.version + ? manifest.version + : null; + } catch { + return null; + } +} + +function installClaudeCodePluginCache(pluginRoot) { + const version = claudeCodePluginVersion(pluginRoot); + if (!version) { + throw new Error(`missing version in ${join(pluginRoot, ".claude-plugin", "plugin.json")}`); + } + const cacheDir = join(CLAUDE_CODE_PLUGIN_CACHE_DIR, version); + rmSync(cacheDir, { recursive: true, force: true }); + mkdirSync(dirname(cacheDir), { recursive: true }); + cpSync(pluginRoot, cacheDir, { + recursive: true, + force: true, + verbatimSymlinks: false, + filter: shouldCopyPath, + }); + patchClaudeCodeMcpConfig(cacheDir); + appendInstallLog("installed Claude Code plugin cache from local package", { + host: HOST_CLAUDE_CODE, + version, + cacheDir, + source: pluginRoot, + }); + return cacheDir; +} + function semverLikePathName(path) { const base = String(path).split(/[\\/]/).pop() || ""; const match = base.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); @@ -937,10 +997,7 @@ async function bootstrapClaudeCodeInstall() { ); } forcePluginRoot(pluginRoot); - const bash = resolveCommand(isWindows() ? ["bash.exe", "bash"] : ["bash"]); - if (!bash) { - throw new Error("bash is required to bootstrap claude-smart dependencies"); - } + const bash = requireMcpBash(); const code = await runChecked(bash, [join(pluginRoot, "scripts", "smart-install.sh")], { cwd: pluginRoot, }); @@ -951,6 +1008,100 @@ async function bootstrapClaudeCodeInstall() { return pluginRoot; } +function bashShellPath(path) { + return isWindows() ? String(path).replace(/\\/g, "/") : String(path); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function resolveMcpBash() { + return resolveUsableBash(); +} + +function requireMcpBash() { + const bash = resolveMcpBash(); + if (bash) return bash; + throw new Error( + isWindows() + ? "Git Bash is required for claude-smart MCP tools on Windows. Install Git for Windows and ensure bash.exe is on PATH, or run from WSL" + : "bash is required for claude-smart MCP tools but was not found on PATH", + ); +} + +function learningsMcpShellScript(pluginRoot, bash) { + const bashForShell = shellQuote(bashShellPath(bash)); + const pluginRootForShell = shellQuote(bashShellPath(pluginRoot)); + return [ + `_B=${bashForShell}`, + '_R=$(readlink "$HOME/.reflexio/plugin-root" 2>/dev/null || true)', + 'if [ -z "$_R" ] && [ -f "$HOME/.reflexio/plugin-root.txt" ]; then _R=$(cat "$HOME/.reflexio/plugin-root.txt" 2>/dev/null || true); fi', + `if [ -z "$_R" ] || [ ! -f "\${_R%/}/scripts/mcp-server.sh" ]; then _R=${pluginRootForShell}; fi`, + 'if [ -z "$_R" ] || [ ! -f "${_R%/}/scripts/mcp-server.sh" ]; then _R=$(ls -dt "$HOME/.claude/plugins/cache/reflexioai/claude-smart"/*/ "$HOME/.codex/plugins/cache/reflexioai/claude-smart"/*/ 2>/dev/null | head -n 1); fi', + '[ -n "$_R" ] || exit 1', + 'CLAUDE_SMART_BASH=$_B exec "$_B" "${_R%/}/scripts/mcp-server.sh"', + ].join("; "); +} + +function claudeCodeLearningsMcpConfig(pluginRoot) { + const bash = requireMcpBash(); + const script = learningsMcpShellScript(pluginRoot, bash); + return { + command: bash, + args: ["-lc", script], + }; +} + +function patchMcpServerConfig(pluginRoot, configPath, serverPath) { + const config = JSON.parse(readFileSync(configPath, "utf8")); + const target = serverPath.reduce((value, key) => value && value[key], config); + if (!target || typeof target !== "object") { + throw new Error(`missing learnings MCP server config in ${configPath}`); + } + Object.assign(target, claudeCodeLearningsMcpConfig(pluginRoot)); + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); +} + +function patchClaudeCodeMcpConfig(pluginRoot) { + patchMcpServerConfig(pluginRoot, join(pluginRoot, ".mcp.json"), [ + "mcpServers", + "learnings", + ]); +} + +function patchCodexMcpConfig(pluginRoot) { + patchMcpServerConfig(pluginRoot, join(pluginRoot, "hooks", "codex-mcp.json"), [ + "learnings", + ]); +} + +async function registerClaudeCodeLearningsMcp(pluginRoot) { + const config = claudeCodeLearningsMcpConfig(pluginRoot); + await runClaude(["mcp", "remove", "learnings", "--scope", "user"]); + const code = await runClaude([ + "mcp", + "add-json", + "--scope", + "user", + "learnings", + JSON.stringify(config), + ]); + appendInstallLog("registered Claude Code learnings MCP", { + host: HOST_CLAUDE_CODE, + pluginRoot, + status: code === 0 ? "ok" : "failed", + }); + if (code !== 0) { + process.stderr.write( + "warning: could not register Claude Code user-scope MCP `learnings`; " + + "native plugin MCP may still work, or run `claude mcp add-json --scope user learnings ...` manually.\n", + ); + return false; + } + return true; +} + function isWindows() { return currentPlatform() === "win32"; } @@ -2011,7 +2162,14 @@ function installCodexPluginCache(pluginRoot) { force: true, verbatimSymlinks: false, }); + patchCodexMcpConfig(cacheDir); setCodexPluginEnabled(); + appendInstallLog("installed Codex plugin cache from local package", { + host: HOST_CODEX, + version, + cacheDir, + source: pluginRoot, + }); return cacheDir; } @@ -2102,6 +2260,20 @@ async function runUninstall(args) { ); process.exit(code); } + // Symmetric with registerClaudeCodeLearningsMcp: without this, the + // user-scope registration outlives the plugin caches and every future + // session shows a failing `learnings` MCP server. + const mcpRemoveCode = await runClaude([ + "mcp", + "remove", + "learnings", + "--scope", + "user", + ]); + appendInstallLog("removed Claude Code learnings MCP", { + host: HOST_CLAUDE_CODE, + status: mcpRemoveCode === 0 ? "ok" : "failed", + }); stopClaudeSmartServices(join(PACKAGE_ROOT, "plugin")); process.stdout.write( @@ -2181,6 +2353,7 @@ async function runInstall(args, options = {}) { } try { + installClaudeCodePluginCache(join(PACKAGE_ROOT, "plugin")); const pluginRoot = await bootstrapClaudeCodeInstall(); restorePublishHooksFromSource(pluginRoot); if (readOnly) { @@ -2188,10 +2361,21 @@ async function runInstall(args, options = {}) { process.stdout.write("Installed read-only hook manifest; publish interactions hooks are disabled.\n"); } process.stdout.write(`Prepared claude-smart runtime at ${pluginRoot}.\n`); + if (await registerClaudeCodeLearningsMcp(pluginRoot)) { + process.stdout.write("Registered Claude Code search_learnings MCP tool.\n"); + } if (startBackendService(pluginRoot, HOST_CLAUDE_CODE)) { + appendInstallLog("started claude-smart backend service", { + host: HOST_CLAUDE_CODE, + pluginRoot, + }); process.stdout.write("Started claude-smart backend service.\n"); } if (refreshDashboardService(pluginRoot)) { + appendInstallLog("refreshed claude-smart dashboard service", { + host: HOST_CLAUDE_CODE, + pluginRoot, + }); process.stdout.write("Refreshed claude-smart dashboard service.\n"); } } catch (err) { @@ -2272,9 +2456,17 @@ async function runInstallCodex(args) { process.stdout.write("Installed read-only hook manifest; publish interactions hooks are disabled.\n"); } if (startBackendService(cacheDir, HOST_CODEX)) { + appendInstallLog("started claude-smart backend service", { + host: HOST_CODEX, + pluginRoot: cacheDir, + }); process.stdout.write("Started claude-smart backend service.\n"); } if (refreshDashboardService(cacheDir)) { + appendInstallLog("refreshed claude-smart dashboard service", { + host: HOST_CODEX, + pluginRoot: cacheDir, + }); process.stdout.write("Refreshed claude-smart dashboard service.\n"); } } catch (err) { @@ -2512,4 +2704,7 @@ module.exports = { prunePublishHooksForReadOnly, restorePublishHooksFromSource, stripJsonc, + claudeCodeLearningsMcpConfig, + installClaudeCodePluginCache, + registerClaudeCodeLearningsMcp, }; diff --git a/package.json b/package.json index 9e7e1e8..4f13760 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "plugin", "!plugin/**/__pycache__", "!plugin/**/*.py[cod]", + "!plugin/.coverage", + "!plugin/htmlcov", "!plugin/**/.pytest_cache", "!plugin/**/.ruff_cache", "!plugin/**/.mypy_cache", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index feb34a6..fe6567b 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -5,6 +5,7 @@ "author": { "name": "Yi Lu" }, + "mcpServers": "./.mcp.json", "keywords": [ "claude", "claude-code", diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index dfc3771..efa48fc 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -19,6 +19,7 @@ ], "skills": "./skills/", "hooks": "./hooks/codex-hooks.json", + "mcpServers": "./hooks/codex-mcp.json", "interface": { "displayName": "claude-smart", "shortDescription": "Persistent learning across coding sessions", diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 0000000..6c44ce8 --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "learnings": { + "command": "bash", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.sh" + ] + } + } +} diff --git a/plugin/README.md b/plugin/README.md index b012d1f..d7ff838 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -4,6 +4,11 @@ Self-improving [Claude Code](https://claude.com/claude-code), Codex, and OpenCod This directory is the Claude Code/Codex/OpenCode plugin payload shipped through the marketplace and the `claude-smart` npm package. For the project overview, install instructions, benchmarks, and feature walkthrough, see the [top-level README](https://github.com/ReflexioAI/claude-smart#readme). +Claude Code and Codex also get a read-only `search_learnings` MCP tool. Agents +use it before non-trivial work with a rewritten, task-specific query and the +active repo/workspace absolute path as `cwd`, so claude-smart searches the right +project memories instead of blindly reusing the raw user prompt. + ## Install ### Claude Code diff --git a/plugin/hooks/codex-mcp.json b/plugin/hooks/codex-mcp.json new file mode 100644 index 0000000..06adcdc --- /dev/null +++ b/plugin/hooks/codex-mcp.json @@ -0,0 +1,9 @@ +{ + "learnings": { + "command": "bash", + "args": [ + "-c", + "_R=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.codex/plugins/cache/reflexioai/claude-smart\"/*/ 2>/dev/null | head -n 1); [ -n \"$_R\" ] || exit 1; exec bash \"${_R%/}/scripts/mcp-server.sh\"" + ] + } +} diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 9d5f626..1befe56 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -51,18 +51,6 @@ ] } ], - "PreToolUse": [ - { - "matcher": "Edit|Write|NotebookEdit|Bash", - "hooks": [ - { - "type": "command", - "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/reflexioai/plugin\"; bash \"$_R/scripts/hook_entry.sh\" claude-code pre-tool", - "timeout": 10 - } - ] - } - ], "PostToolUse": [ { "matcher": "*", diff --git a/plugin/pyproject.toml b/plugin/pyproject.toml index 0008f80..60e4011 100644 --- a/plugin/pyproject.toml +++ b/plugin/pyproject.toml @@ -36,6 +36,8 @@ dependencies = [ # Python ranking without this, but installed claude-smart should include # the accelerator by default. "sqlite-vec>=0.1.6", + # mcp.server.fastmcp (used by claude_smart.mcp_server) first shipped in 1.2. + "mcp>=1.2", ] # claude-smart is distributed via npm only (see bin/claude-smart.js); it is not diff --git a/plugin/scripts/_lib.sh b/plugin/scripts/_lib.sh index 72fe928..1644442 100644 --- a/plugin/scripts/_lib.sh +++ b/plugin/scripts/_lib.sh @@ -401,7 +401,7 @@ claude_smart_reexec_stable_plugin_root_if_needed() { [ -f "$stable/scripts/$script" ] || return 0 echo "[claude-smart] redirecting stray plugin copy under ~/.reflexio ($plugin_root) to stable root $stable" >&2 shift 2 - exec bash "$stable/scripts/$script" "$@" + exec "${CLAUDE_SMART_BASH:-bash}" "$stable/scripts/$script" "$@" } claude_smart_download() { diff --git a/plugin/scripts/mcp-server.sh b/plugin/scripts/mcp-server.sh new file mode 100755 index 0000000..0aff30f --- /dev/null +++ b/plugin/scripts/mcp-server.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Start the claude-smart MCP server. Stdout is reserved for MCP frames. +set -eu + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$HERE/_lib.sh" + +claude_smart_source_login_path +claude_smart_prepend_astral_bins +claude_smart_source_reflexio_env + +PLUGIN_ROOT="$(cd "$HERE/.." && pwd)" +claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "mcp-server.sh" "$@" + +try_prepared_mcp_root() { + local candidate candidate_real candidate_python + candidate="$1" + shift || true + [ -n "$candidate" ] || return 1 + [ -f "$candidate/scripts/mcp-server.sh" ] || return 1 + candidate_real="$(claude_smart_canonical_dir "$candidate" 2>/dev/null || true)" + [ -n "$candidate_real" ] || return 1 + [ "$candidate_real" != "$current_real" ] || return 1 + candidate_python="$(claude_smart_plugin_python "$candidate_real")" + [ -x "$candidate_python" ] || return 1 + echo "[claude-smart] redirecting MCP server from unprepared plugin root $PLUGIN_ROOT to prepared root $candidate_real" >&2 + exec "${CLAUDE_SMART_BASH:-bash}" "$candidate_real/scripts/mcp-server.sh" "$@" +} + +PLUGIN_PYTHON="$(claude_smart_plugin_python "$PLUGIN_ROOT")" +if [ ! -x "$PLUGIN_PYTHON" ]; then + current_real="$(claude_smart_canonical_dir "$PLUGIN_ROOT" 2>/dev/null || true)" + try_prepared_mcp_root "$HOME/.reflexio/plugin-root" "$@" || true + if [ -f "$HOME/.reflexio/plugin-root.txt" ]; then + try_prepared_mcp_root "$(cat "$HOME/.reflexio/plugin-root.txt" 2>/dev/null || true)" "$@" || true + fi + for candidate in $(ls -dt "$HOME/.claude/plugins/cache/reflexioai/claude-smart"/* "$HOME/.codex/plugins/cache/reflexioai/claude-smart"/* 2>/dev/null || true); do + try_prepared_mcp_root "$candidate" "$@" || true + done +fi +if [ ! -x "$PLUGIN_PYTHON" ]; then + echo "[claude-smart] MCP server cannot start: no prepared plugin venv at $PLUGIN_PYTHON and no prepared cache fallback; run \`npx claude-smart install\` to rebuild it" >&2 + exit 1 +fi + +exec "$PLUGIN_PYTHON" -m claude_smart.mcp_server diff --git a/plugin/skills/claude-smart/SKILL.md b/plugin/skills/claude-smart/SKILL.md index ea4ebb9..9f2df34 100644 --- a/plugin/skills/claude-smart/SKILL.md +++ b/plugin/skills/claude-smart/SKILL.md @@ -3,7 +3,7 @@ name: claude-smart description: Codex-only — use when the user asks to run claude-smart commands in Codex, including claude-smart show, learn, restart, dashboard, clear-all, or slash-like requests such as /claude-smart:learn. In Claude Code the native plugin slash commands handle these; do not invoke this skill there. --- -# claude-smart Commands In Codex +# claude-smart In Codex Codex does not currently support plugin-provided slash commands. When the user asks for a claude-smart command, run the equivalent shell command through the @@ -11,6 +11,15 @@ active plugin root. This skill is Codex-specific; under Claude Code the native `/claude-smart:*` slash commands already exist, so do not fall back to the shell commands there. +## Learning Search + +Before non-trivial coding, debugging, planning, or repository work, use the +`search_learnings` MCP tool when it is available. Rewrite the query to describe +the actual task or execution path you need help with, and pass the active +repo/workspace absolute path as `cwd` so project-scoped memory is searched. +Skip this only for trivial one-shot questions. If the tool is unavailable, +continue with the automatic hook-injected context. + ## Command Map - Dashboard: `bash ~/.reflexio/plugin-root/scripts/dashboard-open.sh` diff --git a/plugin/src/README.md b/plugin/src/README.md index d2e3485..c3bdd9e 100644 --- a/plugin/src/README.md +++ b/plugin/src/README.md @@ -7,7 +7,8 @@ Description: Python package powering the claude-smart plugin — hook handlers t | File | Purpose | |------|---------| -| `hook.py` | Hook dispatcher. Parses the stdin JSON payload and routes each event (Setup, SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SessionEnd) to its handler in `events/`. | +| `hook.py` | Hook dispatcher. Parses the stdin JSON payload and routes each event (Setup, SessionStart, UserPromptSubmit, PostToolUse, Stop, SessionEnd) to its handler in `events/`. | +| `mcp_server.py` | MCP server that exposes the read-only `search_learnings` tool. | | `cli.py` | `claude-smart` CLI: `install`, `uninstall`, `show`, `learn`, `restart`, `dashboard`, `clear-all`. | | `state.py` | Per-session JSONL buffer at `~/.claude-smart/sessions/{session_id}.jsonl`; high-water mark for idempotent publish retries. | | `publish.py` | Drains the session buffer, calls the Reflexio adapter, and stamps the watermark. Used by Stop, SessionEnd, and `learn`. | @@ -19,7 +20,6 @@ Description: Python package powering the claude-smart plugin — hook handlers t |---------|------|------| | `session_start.py` | SessionStart | Apply extraction defaults, render stall banner, push optimizer context. | | `user_prompt.py` | UserPromptSubmit | Inject learned playbooks/preferences into context. | -| `pre_tool.py` | PreToolUse (Edit/Write/Bash/NotebookEdit) | Inject project-specific context before tool execution. | | `post_tool.py` | PostToolUse | Buffer the tool invocation (name, input, response, duration) into session state. | | `stop.py` | Stop | Publish unpublished interactions (force extraction if configured). | | `session_end.py` | SessionEnd | Final publish + aggregation; optional backend/dashboard shutdown. | @@ -31,7 +31,7 @@ Description: Python package powering the claude-smart plugin — hook handlers t | `env_config.py` | Parses `~/.claude-smart/.env` (`REFLEXIO_URL`, `REFLEXIO_API_KEY`, `CLAUDE_SMART_READ_ONLY`, `CLAUDE_SMART_HOST`, local embedding/CLI flags). | | `runtime.py` | Host detection (Claude Code vs Codex); shared agent version. | | `ids.py` | Session / project ID generation and resolution. | -| `context_inject.py`, `context_format.py`, `query_compose.py`, `cs_cite.py` | Build search queries, format learned skills as markdown, inject into context, format citations. | +| `context_inject.py`, `context_format.py`, `learnings_search.py`, `cs_cite.py` | Search learnings, format learned skills as markdown, inject into context, format citations. | | `stall_banner.py` | User-facing message when the Reflexio provider hits an auth/billing stall. | | `optimizer_assistant.py` | Claude-code CLI agent that extracts Reflexio optimization hints. | | `hook_log.py`, `internal_call.py` | Structured JSON logging to `~/.claude-smart/hook.log`; detect internal/test invocations to skip learning. | @@ -42,7 +42,8 @@ Description: Python package powering the claude-smart plugin — hook handlers t SessionStart -> bootstrap Reflexio backend (8071) + Next.js dashboard (3001) PostToolUse -> state.py buffers each tool call to ~/.claude-smart/sessions/{id}.jsonl Stop/SessionEnd -> publish.py -> reflexio_adapter -> Reflexio extracts playbooks + preferences -UserPromptSubmit / PreToolUse -> context_inject pulls relevant learnings back into context +UserPromptSubmit -> context_inject pulls relevant learnings back into context +search_learnings MCP tool -> learnings_search returns model-requested learnings ``` - **Dual-host** — the same package runs under Claude Code (native slash commands) and Codex (shell-script fallbacks in `../scripts/`); host shape is normalized in `runtime.py`. diff --git a/plugin/src/claude_smart/context_format.py b/plugin/src/claude_smart/context_format.py index 09b5b9f..e0f0f10 100644 --- a/plugin/src/claude_smart/context_format.py +++ b/plugin/src/claude_smart/context_format.py @@ -197,6 +197,73 @@ def render_inline_with_registry( return "\n".join(sections) + "\n", playbook_entries + profile_entries +def render_learnings_plain( + *, + project_id: str, + user_playbooks: Iterable[Any], + agent_playbooks: Iterable[Any], + profiles: Iterable[Any], +) -> str: + """Render skills + preferences for model-requested (MCP) search results. + + Unlike ``render_inline_with_registry`` this emits no ``[cs:…]`` ids, no + rank-based ``/rules/`` URLs, and no citation instruction: MCP tool output + never reaches the per-session citation registry (the MCP server has no + ``session_id``), so rank ids emitted here could never be resolved by the + Stop hook. Dashboard links use the stable real-id routes instead. + + Args: + project_id (str): Reserved for future use; currently unused. + user_playbooks (Iterable[Any]): Relevance-ranked project-scoped hits. + agent_playbooks (Iterable[Any]): Relevance-ranked global hits. + profiles (Iterable[Any]): Relevance-ranked preference hits. + + Returns: + str: Markdown with ``### Relevant project-specific skills`` and/or + ``### Relevant project preferences`` sub-sections, or ``""`` + when all inputs are empty. + """ + del project_id # kept for symmetry with ``render_inline_with_registry``. + skill_lines: list[str] = [] + for playbooks, id_field, source_kind in ( + (agent_playbooks, "agent_playbook_id", "agent_playbook"), + (user_playbooks, "user_playbook_id", "user_playbook"), + ): + for pb in playbooks: + content = _first_nonempty(_field(pb, "content")) + if not content: + continue + bullet = f"- {content}" + if trigger := _first_nonempty(_field(pb, "trigger")): + bullet += f" _(when: {trigger})_" + if rationale := _first_nonempty(_field(pb, "rationale")): + bullet += f" — *why:* {rationale}" + if url := _dashboard_url("playbook", _field(pb, id_field), source_kind): + bullet += f" _(open: {url})_" + skill_lines.append(bullet) + + profile_lines: list[str] = [] + for p in profiles: + content = _first_nonempty(_field(p, "content")) + if not content: + continue + bullet = f"- {content}" + if url := _dashboard_url("profile", _field(p, "profile_id")): + bullet += f" _(open: {url})_" + profile_lines.append(bullet) + + if not skill_lines and not profile_lines: + return "" + sections: list[str] = [] + if skill_lines: + sections.append("### Relevant project-specific skills") + sections.extend(skill_lines) + if profile_lines: + sections.append("### Relevant project preferences") + sections.extend(profile_lines) + return "\n".join(sections) + "\n" + + def render_inline_compact_with_registry( *, project_id: str, diff --git a/plugin/src/claude_smart/context_inject.py b/plugin/src/claude_smart/context_inject.py index d21d7c9..b8df76c 100644 --- a/plugin/src/claude_smart/context_inject.py +++ b/plugin/src/claude_smart/context_inject.py @@ -1,17 +1,9 @@ -"""Shared "search reflexio, render markdown, emit hookSpecificOutput" pipeline. +"""Shared UserPromptSubmit search/render/emit pipeline. -PreToolUse and UserPromptSubmit both (a) run a query-aware reflexio -search, (b) render the hits with ``context_format.render_inline_with_registry``, -(c) persist the citation registry for the Stop hook to resolve, and -(d) emit a Claude Code ``hookSpecificOutput.additionalContext`` envelope -on stdout. This module owns that shared pipeline so the two hook -handlers keep exactly one source of truth for the injection contract — -the envelope shape, the registry schema, and the injected context append. - -The caller remains responsible for handler-specific framing (PreToolUse -needs ``hook.emit_continue()`` on the empty path; UserPromptSubmit wraps -the search in ``try/except`` so a failed reflexio never breaks a user's -turn) — see the two call sites for the small policy differences. +The hook runs a query-aware reflexio search, renders the hits with +``context_format.render_inline_with_registry``, persists the citation +registry for the Stop hook to resolve, and emits a Claude Code +``hookSpecificOutput.additionalContext`` envelope on stdout. """ from __future__ import annotations @@ -44,7 +36,7 @@ def emit_context( ``/api/search`` endpoint, which fans out to user playbooks (project-scoped), agent playbooks (global), and preferences (project-scoped) server-side. - hook_event_name (str): ``"PreToolUse"`` or ``"UserPromptSubmit"``; + hook_event_name (str): ``"UserPromptSubmit"``; echoed verbatim in the hook envelope so Claude Code attributes the context to the right event. top_k (int): Cap on hits per collection. diff --git a/plugin/src/claude_smart/cs_cite.py b/plugin/src/claude_smart/cs_cite.py index 14fc4d4..ecd0f1e 100644 --- a/plugin/src/claude_smart/cs_cite.py +++ b/plugin/src/claude_smart/cs_cite.py @@ -1,6 +1,6 @@ """Support helpers for claude-smart citation tracking. -Context injected by UserPromptSubmit / PreToolUse tags each skill and +Context injected by UserPromptSubmit tags each skill and preference bullet with a rank-based id fingerprinted by the underlying real id (``[cs:s1-1a2b]`` for the first skill whose ``user_playbook_id`` starts with ``1a2b``, ``[cs:p2-c3d4]`` for the diff --git a/plugin/src/claude_smart/events/pre_tool.py b/plugin/src/claude_smart/events/pre_tool.py deleted file mode 100644 index 0cee3b1..0000000 --- a/plugin/src/claude_smart/events/pre_tool.py +++ /dev/null @@ -1,52 +0,0 @@ -"""PreToolUse hook — just-in-time skill + preference inject before a mutating tool. - -Fires only for tools listed in ``hooks.json``'s PreToolUse matcher -(Edit/Write/NotebookEdit/Bash). Composes a query from the tool call and -delegates to ``context_inject.emit_context`` for the shared -search-render-emit pipeline, falling back to ``hook.emit_continue`` when -there is nothing to inject or the search raises. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from claude_smart import context_inject, hook, ids, query_compose, runtime - -_LOGGER = logging.getLogger(__name__) -_TOP_K = 3 - - -def handle(payload: dict[str, Any]) -> None: - """PreToolUse dispatcher — never raises; degrades to ``emit_continue``.""" - if runtime.is_codex(): - hook.emit_continue() - return - - session_id = payload.get("session_id") - tool_name = payload.get("tool_name") - tool_input = payload.get("tool_input") or {} - if not session_id or not tool_name: - hook.emit_continue() - return - - query = query_compose.from_tool_call(tool_name, tool_input) - if not query: - hook.emit_continue() - return - - project_id = ids.resolve_user_id(payload.get("cwd")) - try: - emitted = context_inject.emit_context( - session_id=session_id, - project_id=project_id, - query=query, - hook_event_name="PreToolUse", - top_k=_TOP_K, - ) - except Exception as exc: # noqa: BLE001 — never block a tool call. - _LOGGER.debug("pre_tool context inject failed: %s", exc) - emitted = False - if not emitted: - hook.emit_continue() diff --git a/plugin/src/claude_smart/events/user_prompt.py b/plugin/src/claude_smart/events/user_prompt.py index 98dcd20..6ddf524 100644 --- a/plugin/src/claude_smart/events/user_prompt.py +++ b/plugin/src/claude_smart/events/user_prompt.py @@ -9,17 +9,9 @@ skills and emit the top hits as ``hookSpecificOutput.additionalContext`` so Claude sees relevant rules before planning the response. -The PreToolUse hook does similar retrieval keyed to tool-call text; this -hook covers the gap where a prompt-only turn (e.g. a question answered -from context without edits) never fires PreToolUse and so would otherwise -see no injected context at all. The shared pipeline lives in -``context_inject.emit_context``. - Retrieval is best-effort: any failure from search (reflexio unreachable, HTTP timeout, unexpected shape) is caught so the buffered-prompt -behaviour is always preserved. PreToolUse does not wrap — a tool-call -injection failure is invisible to the user, whereas a failed user-turn -would silently lose the prompt. +behaviour is always preserved. """ from __future__ import annotations diff --git a/plugin/src/claude_smart/hook.py b/plugin/src/claude_smart/hook.py index cb42c3f..0e61d8c 100644 --- a/plugin/src/claude_smart/hook.py +++ b/plugin/src/claude_smart/hook.py @@ -37,7 +37,6 @@ def _load_handlers() -> dict[str, Callable[[dict[str, Any]], Any]]: from claude_smart.events import ( post_tool, - pre_tool, session_end, session_start, stop, @@ -47,7 +46,6 @@ def _load_handlers() -> dict[str, Callable[[dict[str, Any]], Any]]: return { "session-start": session_start.handle, "user-prompt": user_prompt.handle, - "pre-tool": pre_tool.handle, "post-tool": post_tool.handle, "stop": stop.handle, "session-end": session_end.handle, diff --git a/plugin/src/claude_smart/learnings_search.py b/plugin/src/claude_smart/learnings_search.py new file mode 100644 index 0000000..7aec4ce --- /dev/null +++ b/plugin/src/claude_smart/learnings_search.py @@ -0,0 +1,80 @@ +"""Search claude-smart learnings for model-callable MCP tools.""" + +from __future__ import annotations + +from pathlib import Path + +from claude_smart import context_format, ids +from claude_smart.reflexio_adapter import Adapter + +_DEFAULT_TOP_K = 5 +_MAX_TOP_K = 10 + + +def search_learnings( + *, + query: str, + cwd: str, + top_k: int = _DEFAULT_TOP_K, + adapter: Adapter | None = None, +) -> str: + """Return markdown learnings for a task-specific search query. + + Args: + query: Standalone search query rewritten from the current task context. + cwd: Absolute repo/workspace path used to resolve project-scoped memory. + top_k: Maximum results per entity type; clamped to ``1..10``. + adapter: Optional adapter injection seam for tests. + + Returns: + Markdown text suitable for direct MCP tool output. The function never + raises for invalid input or an unavailable backend; those cases return a + short, model-readable instruction/result instead. + """ + normalized_query = query.strip() + if not normalized_query: + return "No search query was provided. Retry `search_learnings` with a concise, task-specific query." + + cwd_path = Path(cwd).expanduser() if cwd else None + if cwd_path is None or not cwd_path.is_absolute(): + return ( + "search_learnings needs an absolute `cwd` for the active repo/workspace " + "so claude-smart can search the correct project memory. Retry with the " + "current workspace path." + ) + + project_id = ids.resolve_user_id(str(cwd_path)) + search_adapter = adapter or Adapter() + user_playbooks, agent_playbooks, profiles = search_adapter.search_all( + project_id=project_id, + query=normalized_query, + top_k=_normalize_top_k(top_k), + session_id=None, + ) + # Plain render: MCP output is not tracked in the per-session citation + # registry, so citation ids/instructions would produce markers the Stop + # hook can never resolve. + markdown = context_format.render_learnings_plain( + project_id=project_id, + user_playbooks=user_playbooks, + agent_playbooks=agent_playbooks, + profiles=profiles, + ) + if markdown: + return f"## claude-smart search_learnings — project `{project_id}`\n{markdown}" + + if search_adapter.read_errors: + latest = search_adapter.read_errors[-1] + return ( + f"claude-smart search is unavailable for project `{project_id}`: {latest}. " + "Proceed normally with the current context." + ) + return f"No relevant claude-smart learnings found for project `{project_id}`." + + +def _normalize_top_k(top_k: int) -> int: + try: + parsed = int(top_k) + except (TypeError, ValueError): + return _DEFAULT_TOP_K + return max(1, min(parsed, _MAX_TOP_K)) diff --git a/plugin/src/claude_smart/mcp_server.py b/plugin/src/claude_smart/mcp_server.py new file mode 100644 index 0000000..5f164ed --- /dev/null +++ b/plugin/src/claude_smart/mcp_server.py @@ -0,0 +1,39 @@ +"""MCP server exposing claude-smart learning search.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from claude_smart.learnings_search import search_learnings as _search_learnings + +_TOOL_DESCRIPTION = """Search claude-smart learnings: previous user corrections, project-specific skills, shared skills, and project preferences. + +Use before non-trivial coding, debugging, planning, or repository work, and again when the task changes topic. Rewrite `query` into the actual question or execution path you need help with; do not blindly pass the raw user prompt. Always pass the active repo/workspace absolute path as `cwd` so project-scoped memory is correct. Skip only for trivial one-shot questions.""" + +mcp = FastMCP( + "claude-smart learnings", + instructions=( + "Use search_learnings to retrieve prior user corrections, preferences, " + "memories, and optimized execution paths from claude-smart." + ), +) + + +@mcp.tool( + name="search_learnings", + description=_TOOL_DESCRIPTION, + annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False), +) +def search_learnings(query: str, cwd: str, top_k: int = 5) -> str: + """Search claude-smart learnings for the active workspace.""" + return _search_learnings(query=query, cwd=cwd, top_k=top_k) + + +def main() -> None: + """Run the MCP server over stdio.""" + mcp.run("stdio") + + +if __name__ == "__main__": + main() diff --git a/plugin/src/claude_smart/query_compose.py b/plugin/src/claude_smart/query_compose.py deleted file mode 100644 index 7ad8197..0000000 --- a/plugin/src/claude_smart/query_compose.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Compose a reflexio search query from a PreToolUse payload. - -Deterministic — no LLM call — so the PreToolUse hook can stay inside its -latency budget. The output is fed to ``ReflexioClient.search(query=...)`` -(the unified ``/api/search`` endpoint, which fans out to user playbooks, -agent playbooks, and preferences server-side), which tokenizes via reflexio's -FTS5 sanitizer (OR-joined, stemmed) plus a vector-similarity leg. Short, -meaning-dense strings give the most selective hybrid ranking. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, Mapping - -_MAX_SNIPPET_LEN = 400 - - -def from_tool_call(tool_name: str, tool_input: Mapping[str, Any]) -> str: - """Compose a search query from a Claude Code PreToolUse payload. - - Args: - tool_name (str): Claude Code tool name (e.g. ``"Edit"``, ``"Bash"``). - tool_input (Mapping[str, Any]): The tool's input dict as delivered - by the hook payload. - - Returns: - str: A short query suitable for reflexio hybrid search, or ``""`` - when the tool is not one we compose for (caller should then - skip the search entirely). - """ - match tool_name: - case "Edit" | "Write" | "NotebookEdit": - return _from_file_edit(tool_input) - case "Bash": - return _from_bash(tool_input) - case _: - return "" - - -def _from_file_edit(tool_input: Mapping[str, Any]) -> str: - path = tool_input.get("file_path") or "" - snippet = tool_input.get("new_string") or tool_input.get("content") or "" - basename = Path(path).name if path else "" - return f"{basename} {snippet[:_MAX_SNIPPET_LEN]}".strip() - - -def _from_bash(tool_input: Mapping[str, Any]) -> str: - command = tool_input.get("command") or "" - first_line = command.splitlines()[0] if command else "" - return first_line[:_MAX_SNIPPET_LEN].strip() diff --git a/plugin/src/claude_smart/reflexio_adapter.py b/plugin/src/claude_smart/reflexio_adapter.py index 8077d84..dace33c 100644 --- a/plugin/src/claude_smart/reflexio_adapter.py +++ b/plugin/src/claude_smart/reflexio_adapter.py @@ -81,6 +81,7 @@ def _get_client(self) -> Any | None: from reflexio import ReflexioClient # type: ignore[import-not-found] except ImportError as exc: _LOGGER.debug("reflexio not importable: %s", exc) + self._record_read_error("import reflexio", exc) return None try: try: @@ -97,6 +98,7 @@ def _get_client(self) -> Any | None: ) except Exception as exc: # noqa: BLE001 — adapter must never raise. _LOGGER.warning("Failed to construct ReflexioClient: %s", exc) + self._record_read_error("construct ReflexioClient", exc) return None return self._client @@ -353,7 +355,7 @@ def fetch_project_profiles(self, project_id: str, top_k: int = 20) -> list[Any]: return _extract_items(response, "user_profiles") # ----------------------------------------------------------------- - # Query-aware unified search (used by PreToolUse / UserPromptSubmit) + # Query-aware unified search (used by UserPromptSubmit and search_learnings) # ----------------------------------------------------------------- def search_all( diff --git a/plugin/uv.lock b/plugin/uv.lock index a1e64e1..cdf5289 100644 --- a/plugin/uv.lock +++ b/plugin/uv.lock @@ -481,6 +481,7 @@ source = { editable = "." } dependencies = [ { name = "chromadb" }, { name = "einops" }, + { name = "mcp" }, { name = "reflexio-ai" }, { name = "sqlite-vec" }, ] @@ -494,6 +495,7 @@ dev = [ requires-dist = [ { name = "chromadb", specifier = ">=0.5" }, { name = "einops", specifier = ">=0.8.0" }, + { name = "mcp", specifier = ">=1.2" }, { name = "reflexio-ai", specifier = ">=0.2.28" }, { name = "sqlite-vec", specifier = ">=0.1.6" }, ] @@ -1140,6 +1142,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "1.16.1" @@ -1541,6 +1552,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2608,6 +2644,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pypika" version = "0.51.1" @@ -2677,6 +2727,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-slugify" version = "8.0.4" @@ -2689,6 +2748,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3242,6 +3320,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + [[package]] name = "sseclient-py" version = "1.9.0" diff --git a/tests/test_codex_support.py b/tests/test_codex_support.py index e5ba2e2..4f4f4ef 100644 --- a/tests/test_codex_support.py +++ b/tests/test_codex_support.py @@ -3,14 +3,12 @@ from __future__ import annotations import argparse -import io import json -import sys from pathlib import Path from typing import Any from claude_smart import cli, cs_cite, hook, runtime, state -from claude_smart.events import post_tool, pre_tool, stop +from claude_smart.events import post_tool, stop REPO_ROOT = Path(__file__).resolve().parents[1] @@ -34,15 +32,41 @@ def test_codex_manifest_points_at_codex_hooks() -> None: manifest = _read_json("plugin/.codex-plugin/plugin.json") assert manifest["name"] == "claude-smart" assert manifest["hooks"] == "./hooks/codex-hooks.json" + assert manifest["mcpServers"] == "./hooks/codex-mcp.json" assert manifest["skills"] == "./skills/" assert manifest["interface"]["displayName"] == "claude-smart" +def test_codex_mcp_config_is_direct_server_map_with_cache_fallback() -> None: + config = _read_json("plugin/hooks/codex-mcp.json") + assert set(config) == {"learnings"} + server = config["learnings"] + assert server["command"] == "bash" + command = " ".join(server["args"]) + assert "CLAUDE_PLUGIN_ROOT" in command + assert "PLUGIN_ROOT" in command + assert ".codex/plugins/cache/reflexioai/claude-smart" in command + assert "scripts/mcp-server.sh" in command + + +def test_claude_code_manifest_points_at_wrapped_mcp_config() -> None: + manifest = _read_json("plugin/.claude-plugin/plugin.json") + config = _read_json("plugin/.mcp.json") + + assert manifest["mcpServers"] == "./.mcp.json" + assert set(config) == {"mcpServers"} + server = config["mcpServers"]["learnings"] + assert server["command"] == "bash" + assert "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.sh" in server["args"] + + def test_codex_skill_documents_command_mapping() -> None: skill = (REPO_ROOT / "plugin" / "skills" / "claude-smart" / "SKILL.md").read_text() assert "name: claude-smart" in skill assert "Codex does not currently support plugin-provided slash commands" in skill + assert "search_learnings" in skill + assert "repo/workspace absolute path" in skill assert "bash ~/.reflexio/plugin-root/scripts/cli.sh show" in skill assert "bash ~/.reflexio/plugin-root/scripts/cli.sh learn --note" in skill assert "bash ~/.reflexio/plugin-root/scripts/cli.sh restart" in skill @@ -225,30 +249,6 @@ def fake_handlers(): assert calls == [("claude-code", "stop"), ("codex", "stop")] -def test_codex_pre_tool_does_not_inject_context(monkeypatch) -> None: - runtime.set_host(runtime.HOST_CODEX) - called = False - - def fail_emit(**_kwargs): - nonlocal called - called = True - - monkeypatch.setattr(pre_tool.context_inject, "emit_context", fail_emit) - buf = io.StringIO() - monkeypatch.setattr(sys, "stdout", buf) - - pre_tool.handle( - { - "session_id": "s1", - "tool_name": "Bash", - "tool_input": {"command": "uv run pytest"}, - } - ) - - assert called is False - assert json.loads(buf.getvalue()) == {"continue": True} - - def test_codex_citation_instruction_uses_text_marker_not_tool_call() -> None: runtime.set_host(runtime.HOST_CODEX) diff --git a/tests/test_events.py b/tests/test_events.py index fb0414f..df7f6fb 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1738,113 +1738,6 @@ def fetch_all(self, **kwargs): assert calls == [] -# ----------------------------------------------------------------------------- -# pre_tool -# ----------------------------------------------------------------------------- - - -def _stub_pretool_adapter( - monkeypatch, - *, - playbooks=None, - agent_playbooks=None, - profiles=None, - calls=None, -): - class Stub: - def search_all(self, **kwargs): - if calls is not None: - calls.append(kwargs) - return ( - list(playbooks or []), - list(agent_playbooks or []), - list(profiles or []), - ) - - monkeypatch.setattr("claude_smart.context_inject.Adapter", lambda *a, **kw: Stub()) - monkeypatch.setattr( - "claude_smart.events.pre_tool.ids.resolve_user_id", - lambda *_a, **_kw: "demo", - ) - - -def test_pre_tool_emits_continue_without_session_id(session_dir, monkeypatch) -> None: - from claude_smart.events import pre_tool - - buf = io.StringIO() - monkeypatch.setattr(sys, "stdout", buf) - pre_tool.handle({"tool_name": "Edit", "tool_input": {"file_path": "a.py"}}) - assert json.loads(buf.getvalue().strip()) == { - "continue": True, - "suppressOutput": True, - } - - -def test_pre_tool_emits_continue_for_unknown_tool(session_dir, monkeypatch) -> None: - from claude_smart.events import pre_tool - - _stub_pretool_adapter(monkeypatch) - buf = io.StringIO() - monkeypatch.setattr(sys, "stdout", buf) - pre_tool.handle( - {"session_id": "s1", "tool_name": "Read", "tool_input": {"file_path": "a.py"}} - ) - assert json.loads(buf.getvalue().strip()) == { - "continue": True, - "suppressOutput": True, - } - - -def test_pre_tool_injects_context_when_hits_present(session_dir, monkeypatch) -> None: - from claude_smart.events import pre_tool - - calls: list[dict[str, Any]] = [] - _stub_pretool_adapter( - monkeypatch, - playbooks=[{"content": "run uv sync after edits", "trigger": "pyproject.toml"}], - profiles=[{"content": "prefers anyio over asyncio"}], - calls=calls, - ) - buf = io.StringIO() - monkeypatch.setattr(sys, "stdout", buf) - pre_tool.handle( - { - "session_id": "s1", - "tool_name": "Edit", - "tool_input": {"file_path": "src/x/pyproject.toml", "new_string": "dep"}, - } - ) - payload = json.loads(buf.getvalue().strip()) - assert payload["hookSpecificOutput"]["hookEventName"] == "PreToolUse" - markdown = payload["hookSpecificOutput"]["additionalContext"] - assert "run uv sync after edits" in markdown - assert "prefers anyio over asyncio" in markdown - # Composed query must reach the adapter scoped to the project. - assert calls[0]["project_id"] == "demo" - assert "pyproject.toml" in calls[0]["query"] - # Session id scopes server-side injection dedup. - assert calls[0]["session_id"] == "s1" - - -def test_pre_tool_emits_continue_when_search_empty(session_dir, monkeypatch) -> None: - from claude_smart.events import pre_tool - - _stub_pretool_adapter(monkeypatch, playbooks=[], profiles=[]) - buf = io.StringIO() - monkeypatch.setattr(sys, "stdout", buf) - pre_tool.handle( - { - "session_id": "s1", - "tool_name": "Bash", - "tool_input": {"command": "uv run pytest"}, - } - ) - assert json.loads(buf.getvalue().strip()) == { - "continue": True, - "suppressOutput": True, - } - - # ----------------------------------------------------------------------------- # session_end — synthesises Assistant anchor for orphan tool records # ----------------------------------------------------------------------------- diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index 754540d..85d246d 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -1086,6 +1086,20 @@ def test_node_install_reads_managed_env_and_bootstraps_latest_cache( '#!/bin/sh\nprintf \'claude %s\\n\' "$*" >> "$HOME/claude.log"\nexit 0\n' ) claude.chmod(claude.stat().st_mode | stat.S_IXUSR) + bash = fake_bin / "bash" + bash.write_text( + "#!/bin/sh\n" + 'printf \'bash %s\\n\' "$*" >> "$HOME/bash.log"\n' + 'case "$1" in\n' + ' */smart-install.sh)\n' + ' [ "$CLAUDE_SMART_MANAGED_SETUP" = "1" ] || exit 41\n' + ' [ "$REFLEXIO_API_KEY" = "rflx-test-secret" ] || exit 42\n' + ' printf "bootstrapped %s\\n" "$PWD"\n' + " ;;\n" + "esac\n" + "exit 0\n" + ) + bash.chmod(bash.stat().st_mode | stat.S_IXUSR) env = _isolated_env(tmp_path) env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" env_path = tmp_path / ".reflexio" / ".env" @@ -1104,12 +1118,14 @@ def test_node_install_reads_managed_env_and_bootstraps_latest_cache( assert result.returncode == 0, result.stderr assert "Using managed Reflexio" in result.stdout - assert f"Prepared claude-smart runtime at {new_root}" in result.stdout + package_info = json.loads((REPO_ROOT / "package.json").read_text()) + package_root = cache_root / package_info["version"] + assert f"Prepared claude-smart runtime at {package_root}" in result.stdout env_text = env_path.read_text() assert 'REFLEXIO_URL="https://www.reflexio.ai/"' in env_text assert 'REFLEXIO_API_KEY="rflx-test-secret"' in env_text assert "REFLEXIO_USER_ID=" not in env_text - assert (tmp_path / ".reflexio" / "plugin-root").resolve() == new_root + assert (tmp_path / ".reflexio" / "plugin-root").resolve() == package_root def test_npx_install_reads_managed_env(tmp_path: Path) -> None: @@ -1145,6 +1161,18 @@ def test_npx_install_reads_managed_env(tmp_path: Path) -> None: claude = fake_bin / "claude" claude.write_text("#!/bin/sh\nexit 0\n") claude.chmod(claude.stat().st_mode | stat.S_IXUSR) + bash = fake_bin / "bash" + bash.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + ' */smart-install.sh)\n' + ' [ "$CLAUDE_SMART_MANAGED_SETUP" = "1" ] || exit 41\n' + ' [ "$REFLEXIO_API_KEY" = "rflx-test-secret" ] || exit 42\n' + " ;;\n" + "esac\n" + "exit 0\n" + ) + bash.chmod(bash.stat().st_mode | stat.S_IXUSR) pack_dir = tmp_path / "pack" pack_dir.mkdir() @@ -1281,12 +1309,333 @@ def test_node_update_retries_install_after_uninstall(tmp_path: Path) -> None: assert "retrying after uninstalling claude-smart@reflexioai" in result.stderr assert (tmp_path / "install-count").read_text().strip() == "2" claude_log = (tmp_path / "claude.log").read_text().splitlines() - assert claude_log == [ + assert claude_log[:4] == [ f"claude plugin marketplace add {REPO_ROOT}", "claude plugin install claude-smart@reflexioai", "claude plugin uninstall claude-smart@reflexioai", "claude plugin install claude-smart@reflexioai", ] + assert claude_log[4] == "claude mcp remove learnings --scope user" + assert claude_log[5].startswith( + "claude mcp add-json --scope user learnings " + ) + + +def test_node_install_refreshes_claude_cache_and_registers_learnings_mcp( + tmp_path: Path, +) -> None: + node = shutil.which("node") + if not node: + pytest.skip("node is required for Node installer test") + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + claude = fake_bin / "claude" + claude.write_text(_fake_claude_install_script()) + claude.chmod(claude.stat().st_mode | stat.S_IXUSR) + bash = fake_bin / "bash" + bash.write_text( + '#!/bin/sh\nprintf \'bash %s\\n\' "$*" >> "$HOME/bash.log"\nexit 0\n' + ) + bash.chmod(bash.stat().st_mode | stat.S_IXUSR) + + env = _isolated_env(tmp_path) + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + + result = subprocess.run( + [node, str(NODE_INSTALLER), "install"], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + package_info = json.loads((REPO_ROOT / "package.json").read_text()) + cache_root = ( + tmp_path + / ".claude" + / "plugins" + / "cache" + / "reflexioai" + / "claude-smart" + / package_info["version"] + ) + assert (cache_root / "scripts" / "mcp-server.sh").is_file() + assert (cache_root / "src" / "claude_smart" / "mcp_server.py").is_file() + assert (cache_root / "src" / "claude_smart" / "learnings_search.py").is_file() + assert (tmp_path / ".reflexio" / "plugin-root").resolve() == cache_root + cached_mcp = json.loads((cache_root / ".mcp.json").read_text()) + cached_server = cached_mcp["mcpServers"]["learnings"] + assert cached_server["command"] == str(bash) + assert "CLAUDE_SMART_BASH=$_B" in " ".join(cached_server["args"]) + + claude_log = (tmp_path / "claude.log").read_text() + assert f"claude plugin marketplace add {REPO_ROOT}" in claude_log + assert "claude plugin install claude-smart@reflexioai" in claude_log + assert "claude mcp remove learnings --scope user" in claude_log + assert "claude mcp add-json --scope user learnings " in claude_log + assert "mcp-server.sh" in claude_log + assert "search_learnings MCP tool" in result.stdout + + install_log = (tmp_path / ".claude-smart" / "install.log").read_text() + assert "installed Claude Code plugin cache from local package" in install_log + assert "registered Claude Code learnings MCP" in install_log + + +def test_node_claude_mcp_config_uses_git_bash_on_windows(tmp_path: Path) -> None: + node = shutil.which("node") + if not node: + pytest.skip("node is required for Node installer test") + + system32 = tmp_path / "Windows" / "System32" + git_bin = tmp_path / "Git" / "bin" + system32.mkdir(parents=True) + git_bin.mkdir(parents=True) + system_bash = system32 / "bash.exe" + git_bash = git_bin / "bash.exe" + for bash in (system_bash, git_bash): + bash.write_text("#!/bin/sh\nexit 0\n") + bash.chmod(bash.stat().st_mode | stat.S_IXUSR) + + script = ( + "process.env.CLAUDE_SMART_TEST_PLATFORM = 'win32';" + f"process.env.PATH = {json.dumps(str(system32) + ';' + str(git_bin))};" + f"const mod = require({json.dumps(str(NODE_INSTALLER))});" + "const config = mod.claudeCodeLearningsMcpConfig(" + r"'C:\\Users\\Yi Lu\\.claude\\plugins\\cache\\reflexioai\\claude-smart\\0.2.49'" + ");" + "console.log(JSON.stringify(config));" + ) + + result = subprocess.run( + [node, "-e", script], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + config = json.loads(result.stdout) + assert config["command"] == str(git_bash) + command = " ".join(config["args"]) + assert str(system_bash) not in command + assert "C:/Users/Yi Lu/.claude/plugins/cache/reflexioai/claude-smart/0.2.49" in command + assert "CLAUDE_SMART_BASH=$_B" in command + + +def test_mcp_server_redirects_unprepared_root_to_prepared_cache( + tmp_path: Path, +) -> None: + global_root = tmp_path / "npm-global" / "lib" / "node_modules" / "claude-smart" / "plugin" + prepared_root = ( + tmp_path + / ".claude" + / "plugins" + / "cache" + / "reflexioai" + / "claude-smart" + / "0.2.49" + ) + for root in (global_root, prepared_root): + scripts = root / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(REPO_ROOT / "plugin" / "scripts" / "mcp-server.sh", scripts / "mcp-server.sh") + shutil.copy2(LIB, scripts / "_lib.sh") + (root / "pyproject.toml").write_text("[project]\nname='claude-smart'\n") + + python = prepared_root / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$*" >> "$HOME/python.log"\n' + "exit 0\n" + ) + python.chmod(python.stat().st_mode | stat.S_IXUSR) + + env = _isolated_env(tmp_path) + env["CLAUDE_SMART_LOGIN_PATH_TIMEOUT_SECONDS"] = "0" + result = subprocess.run( + ["/bin/bash", "--noprofile", "--norc", str(global_root / "scripts" / "mcp-server.sh")], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "redirecting MCP server from unprepared plugin root" in result.stderr + assert str(prepared_root) in result.stderr + assert "-m claude_smart.mcp_server" in (tmp_path / "python.log").read_text() + + +def test_mcp_server_uses_plugin_root_txt_fallback_for_prepared_cache( + tmp_path: Path, +) -> None: + global_root = tmp_path / "npm-global" / "lib" / "node_modules" / "claude-smart" / "plugin" + prepared_root = tmp_path / "prepared-cache" + for root in (global_root, prepared_root): + scripts = root / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(REPO_ROOT / "plugin" / "scripts" / "mcp-server.sh", scripts / "mcp-server.sh") + shutil.copy2(LIB, scripts / "_lib.sh") + (root / "pyproject.toml").write_text("[project]\nname='claude-smart'\n") + + python = prepared_root / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$*" >> "$HOME/python.log"\n' + "exit 0\n" + ) + python.chmod(python.stat().st_mode | stat.S_IXUSR) + reflexio = tmp_path / ".reflexio" + reflexio.mkdir() + (reflexio / "plugin-root.txt").write_text(str(prepared_root)) + + env = _isolated_env(tmp_path) + env["CLAUDE_SMART_LOGIN_PATH_TIMEOUT_SECONDS"] = "0" + result = subprocess.run( + ["/bin/bash", "--noprofile", "--norc", str(global_root / "scripts" / "mcp-server.sh")], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert str(prepared_root) in result.stderr + assert "-m claude_smart.mcp_server" in (tmp_path / "python.log").read_text() + + +def test_mcp_server_runs_prepared_root_directly(tmp_path: Path) -> None: + prepared_root = ( + tmp_path + / ".claude" + / "plugins" + / "cache" + / "reflexioai" + / "claude-smart" + / "0.2.49" + ) + scripts = prepared_root / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(REPO_ROOT / "plugin" / "scripts" / "mcp-server.sh", scripts / "mcp-server.sh") + shutil.copy2(LIB, scripts / "_lib.sh") + (prepared_root / "pyproject.toml").write_text("[project]\nname='claude-smart'\n") + + python = prepared_root / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$*" >> "$HOME/python.log"\n' + "exit 0\n" + ) + python.chmod(python.stat().st_mode | stat.S_IXUSR) + + env = _isolated_env(tmp_path) + env["CLAUDE_SMART_LOGIN_PATH_TIMEOUT_SECONDS"] = "0" + result = subprocess.run( + ["/bin/bash", "--noprofile", "--norc", str(scripts / "mcp-server.sh")], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "redirecting MCP server" not in result.stderr + assert "-m claude_smart.mcp_server" in (tmp_path / "python.log").read_text() + + +def test_mcp_server_fails_loud_without_any_prepared_venv(tmp_path: Path) -> None: + root = tmp_path / "unprepared-plugin" + scripts = root / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(REPO_ROOT / "plugin" / "scripts" / "mcp-server.sh", scripts / "mcp-server.sh") + shutil.copy2(LIB, scripts / "_lib.sh") + (root / "pyproject.toml").write_text("[project]\nname='claude-smart'\n") + + env = _isolated_env(tmp_path) + env["CLAUDE_SMART_LOGIN_PATH_TIMEOUT_SECONDS"] = "0" + result = subprocess.run( + ["/bin/bash", "--noprofile", "--norc", str(scripts / "mcp-server.sh")], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 1 + assert "MCP server cannot start" in result.stderr + + +def test_node_uninstall_removes_learnings_mcp_registration(tmp_path: Path) -> None: + node = shutil.which("node") + if not node: + pytest.skip("node is required for Node installer test") + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + claude = fake_bin / "claude" + claude.write_text(_fake_claude_install_script()) + claude.chmod(claude.stat().st_mode | stat.S_IXUSR) + bash = fake_bin / "bash" + bash.write_text( + '#!/bin/sh\nprintf \'bash %s\\n\' "$*" >> "$HOME/bash.log"\nexit 0\n' + ) + bash.chmod(bash.stat().st_mode | stat.S_IXUSR) + + env = _isolated_env(tmp_path) + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + + result = subprocess.run( + [node, str(NODE_INSTALLER), "uninstall"], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + claude_log = (tmp_path / "claude.log").read_text().splitlines() + uninstall_index = claude_log.index("claude plugin uninstall claude-smart@reflexioai") + assert "claude mcp remove learnings --scope user" in claude_log[uninstall_index:] + + +def test_npm_pack_ignore_scripts_includes_learnings_mcp_files( + tmp_path: Path, +) -> None: + npm = shutil.which("npm") + if not npm: + pytest.skip("npm is required for npm pack test") + + result = subprocess.run( + [ + npm, + "pack", + "--ignore-scripts", + "--dry-run", + "--json", + "--pack-destination", + str(tmp_path), + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + packed = json.loads(result.stdout)[0] + files = {entry["path"] for entry in packed["files"]} + assert "plugin/.mcp.json" in files + assert "plugin/hooks/codex-mcp.json" in files + assert "plugin/scripts/mcp-server.sh" in files + assert "plugin/src/claude_smart/mcp_server.py" in files + assert "plugin/src/claude_smart/learnings_search.py" in files + assert "plugin/.coverage" not in files + assert not any(path.startswith("plugin/htmlcov/") for path in files) def test_dashboard_service_loads_reflexio_env_for_managed_proxy() -> None: @@ -4271,6 +4620,19 @@ def test_package_tarball_ships_fresh_uv_lock(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr +def test_package_tarball_ships_mcp_data_plane_files(tmp_path: Path) -> None: + tarball = _pack_claude_smart_tarball(tmp_path) + + with tarfile.open(tarball) as archive: + names = set(archive.getnames()) + + assert "package/plugin/.mcp.json" in names + assert "package/plugin/hooks/codex-mcp.json" in names + assert "package/plugin/scripts/mcp-server.sh" in names + assert "package/plugin/src/claude_smart/mcp_server.py" in names + assert "package/plugin/src/claude_smart/learnings_search.py" in names + + def test_opencode_fresh_tarball_install_uses_local_file_plugin( tmp_path: Path, ) -> None: diff --git a/tests/test_learnings_search.py b/tests/test_learnings_search.py new file mode 100644 index 0000000..358c95c --- /dev/null +++ b/tests/test_learnings_search.py @@ -0,0 +1,150 @@ +"""Tests for the model-callable learning search data plane.""" + +from __future__ import annotations + +from typing import Any + +from claude_smart import learnings_search + + +class _Adapter: + def __init__( + self, + *, + playbooks: list[dict[str, Any]] | None = None, + agent_playbooks: list[dict[str, Any]] | None = None, + profiles: list[dict[str, Any]] | None = None, + read_errors: list[str] | None = None, + ) -> None: + self.playbooks = playbooks or [] + self.agent_playbooks = agent_playbooks or [] + self.profiles = profiles or [] + self.read_errors = read_errors or [] + self.calls: list[dict[str, Any]] = [] + + def search_all(self, **kwargs): + self.calls.append(kwargs) + return self.playbooks, self.agent_playbooks, self.profiles + + +def test_search_learnings_passes_query_project_top_k_and_no_session( + monkeypatch, +) -> None: + monkeypatch.setattr(learnings_search.ids, "resolve_user_id", lambda _cwd: "demo") + adapter = _Adapter( + playbooks=[{"content": "use uv run", "user_playbook_id": "abc123"}], + profiles=[{"content": "prefers concise summaries", "profile_id": "pref456"}], + ) + + markdown = learnings_search.search_learnings( + query=" how to run tests ", + cwd="/repo/demo", + top_k=99, + adapter=adapter, + ) + + assert "project `demo`" in markdown + assert "use uv run" in markdown + assert "prefers concise summaries" in markdown + # Real-id dashboard routes, resolvable without the session registry. + assert "http://localhost:3001/skills/project/abc123" in markdown + assert "http://localhost:3001/preferences/project/pref456" in markdown + assert adapter.calls == [ + { + "project_id": "demo", + "query": "how to run tests", + "top_k": 10, + "session_id": None, + } + ] + + +def test_search_learnings_output_is_not_citation_tracked(monkeypatch) -> None: + """MCP hits never enter the per-session citation registry, so the output + must not instruct the model to emit citation markers the Stop hook could + never resolve (no ``[cs:…]`` ids, no rank-based ``/rules/`` URLs).""" + monkeypatch.setattr(learnings_search.ids, "resolve_user_id", lambda _cwd: "demo") + adapter = _Adapter( + playbooks=[{"content": "use uv run", "user_playbook_id": "abc123"}], + profiles=[{"content": "prefers concise summaries", "profile_id": "pref456"}], + ) + + markdown = learnings_search.search_learnings( + query="how to run tests", + cwd="/repo/demo", + adapter=adapter, + ) + + assert "[cs:" not in markdown + assert "/rules/" not in markdown + assert "When to cite:" not in markdown + assert "claude-smart rule applied" not in markdown + + +def test_search_learnings_requires_absolute_cwd() -> None: + adapter = _Adapter(playbooks=[{"content": "should not be searched"}]) + + markdown = learnings_search.search_learnings( + query="q", + cwd="relative/path", + adapter=adapter, + ) + + assert "absolute `cwd`" in markdown + assert adapter.calls == [] + + +def test_search_learnings_empty_query_returns_soft_result() -> None: + adapter = _Adapter(playbooks=[{"content": "should not be searched"}]) + + markdown = learnings_search.search_learnings( + query=" ", + cwd="/repo/demo", + adapter=adapter, + ) + + assert "No search query was provided" in markdown + assert adapter.calls == [] + + +def test_search_learnings_empty_results_are_distinct_from_backend_error( + monkeypatch, +) -> None: + monkeypatch.setattr(learnings_search.ids, "resolve_user_id", lambda _cwd: "demo") + adapter = _Adapter() + + markdown = learnings_search.search_learnings( + query="missing", + cwd="/repo/demo", + adapter=adapter, + ) + + assert markdown == "No relevant claude-smart learnings found for project `demo`." + + +def test_search_learnings_reports_backend_unavailable(monkeypatch) -> None: + monkeypatch.setattr(learnings_search.ids, "resolve_user_id", lambda _cwd: "demo") + adapter = _Adapter(read_errors=["unified search: connection refused"]) + + markdown = learnings_search.search_learnings( + query="q", + cwd="/repo/demo", + adapter=adapter, + ) + + assert "claude-smart search is unavailable" in markdown + assert "unified search: connection refused" in markdown + + +def test_search_learnings_defaults_bad_top_k(monkeypatch) -> None: + monkeypatch.setattr(learnings_search.ids, "resolve_user_id", lambda _cwd: "demo") + adapter = _Adapter() + + learnings_search.search_learnings( + query="q", + cwd="/repo/demo", + top_k="not-int", # type: ignore[arg-type] + adapter=adapter, + ) + + assert adapter.calls[0]["top_k"] == 5 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..c7ddd66 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,31 @@ +"""Tests for the claude-smart MCP server adapter.""" + +from __future__ import annotations + +from claude_smart import mcp_server + + +def test_search_learnings_tool_delegates_to_shared_search(monkeypatch) -> None: + calls: list[dict[str, object]] = [] + + def fake_search(**kwargs): + calls.append(kwargs) + return "ok" + + monkeypatch.setattr(mcp_server, "_search_learnings", fake_search) + + assert mcp_server.search_learnings("question", "/repo/demo", 7) == "ok" + assert calls == [{"query": "question", "cwd": "/repo/demo", "top_k": 7}] + + +def test_search_learnings_tool_uses_default_top_k(monkeypatch) -> None: + calls: list[dict[str, object]] = [] + + def fake_search(**kwargs): + calls.append(kwargs) + return "ok" + + monkeypatch.setattr(mcp_server, "_search_learnings", fake_search) + + assert mcp_server.search_learnings("question", "/repo/demo") == "ok" + assert calls[0]["top_k"] == 5 diff --git a/tests/test_query_compose.py b/tests/test_query_compose.py deleted file mode 100644 index ec5e4ae..0000000 --- a/tests/test_query_compose.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Tests for the deterministic PreToolUse query composer.""" - -from __future__ import annotations - -from claude_smart import query_compose - - -def test_edit_uses_basename_and_snippet() -> None: - q = query_compose.from_tool_call( - "Edit", - {"file_path": "/abs/src/pkg/config.toml", "new_string": "version = '2'"}, - ) - assert "config.toml" in q - assert "version = '2'" in q - # Full absolute path should not leak into the query (noisy for BM25). - assert "/abs/src/pkg/" not in q - - -def test_write_falls_back_to_content_when_new_string_absent() -> None: - q = query_compose.from_tool_call( - "Write", {"file_path": "a.py", "content": "print('hi')"} - ) - assert "a.py" in q - assert "print('hi')" in q - - -def test_bash_uses_only_first_line() -> None: - q = query_compose.from_tool_call( - "Bash", {"command": "git push origin main\nrm -rf node_modules"} - ) - assert q == "git push origin main" - - -def test_bash_truncates_long_command() -> None: - long = "echo " + "x" * 1000 - q = query_compose.from_tool_call("Bash", {"command": long}) - assert len(q) <= 400 - - -def test_unknown_tool_returns_empty() -> None: - assert query_compose.from_tool_call("Read", {"file_path": "a.py"}) == "" - assert query_compose.from_tool_call("Glob", {"pattern": "**/*.py"}) == "" - - -def test_missing_fields_are_tolerated() -> None: - assert query_compose.from_tool_call("Edit", {}) == "" - assert query_compose.from_tool_call("Bash", {}) == ""