diff --git a/README.md b/README.md index 3a250ce..9f8d53c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ RepoLens MCP is an original TypeScript implementation built around fast local ve ## Why It Stands Out - **MCP-native**: exposes 38 tools for indexing, version/update status, repeatable benchmarking, persistent config, project inventory/status, fleet summaries, cross-repo graphing, multi-agent setup, optional startup auto-indexing and git-aware auto-sync, BM25 code search, redacted secret scanning, symbol search, reference lookup, semantic search, vector search, context packs, source snippets, graph schema with relationship patterns and label properties, structural graph search, graph community detection, read-only Cypher-like graph queries, route-call links, runtime trace ingestion, channel/event edges, typed inheritance/implementation/use edges, receiver-aware method call edges, conservative data-flow edges, import-resolved file graphs, multi-ecosystem package manifests, lockfile resolved-dependency graphs, Docker/Kubernetes infrastructure nodes, dependency-cycle detection, architecture reports, architecture summaries, git-history hotspots, tracing, git-change impact, dead-code candidates, maintainable ADR memory, graph snapshots, and graph package exchange. -- **Agent-ready setup**: `doctor` inspects the local Codex MCP configuration, `install-codex` can add a managed MCP block with dry-run and force safeguards, `uninstall-codex` removes only managed RepoLens config, and `agent-setup`/`install-agents` generate reviewable guidance plus opt-in hook/reminder files for Codex, Claude, Gemini, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro. +- **Agent-ready setup**: `doctor` inspects the local Codex MCP configuration, `install-codex` can add a managed MCP block with dry-run and force safeguards, `uninstall-codex` removes only managed RepoLens config, `agent-hook` provides executable non-blocking broad-search reminders, and `agent-setup`/`install-agents` generate reviewable guidance plus opt-in hook/reminder files for Codex, Claude, Gemini, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro, including a managed local Claude PreToolUse hook config. - **Local-first SQLite memory**: all indexed data stays in `.repolens/memory.db`. - **Project catalog and cross-repo graphing**: `list-projects`, `project-status`, `fleet-summary`, `fleet-graph`, and `delete-project` track indexed repositories, aggregate languages/routes/HTTP calls/dependencies, and produce a catalog-wide graph with shared dependencies, route overlaps, and inferred consumer/provider service links. - **Incremental refreshes**: skip unchanged files, prune removed files, preserve the existing graph when a repo has not changed, optionally refresh on MCP startup with `REPOLENS_AUTO_INDEX`, and keep long-running MCP sessions fresh with git-aware `REPOLENS_AUTO_SYNC`. @@ -126,6 +126,7 @@ repolens-mcp uninstall-codex [--dry-run] repolens-mcp agent-setup [--target .] [--agents all|codex,claude,gemini,zed,opencode,antigravity,aider,kilocode,vscode,openclaw,kiro] [--db .repolens/memory.db] [--with-hooks] repolens-mcp install-agents [--target .] [--agents all|codex,claude,gemini,zed,opencode,antigravity,aider,kilocode,vscode,openclaw,kiro] [--dry-run] [--with-hooks] repolens-mcp uninstall-agents [--target .] [--agents all|codex,claude,gemini,zed,opencode,antigravity,aider,kilocode,vscode,openclaw,kiro] [--dry-run] [--with-hooks] +repolens-mcp agent-hook|hook-augment [--db .repolens/memory.db] [--name repolens] [--json|--claude] [--with-query] repolens-mcp decision --title "Use SQLite" --body "Keep memory local." repolens-mcp decision-update 1 --status accepted --tags sqlite,privacy repolens-mcp decision-delete 1 @@ -325,7 +326,7 @@ repolens-mcp install-agents --target . --agents codex,claude,gemini repolens-mcp uninstall-agents --target . --agents codex,claude,gemini --with-hooks --dry-run ``` -`install-agents` writes managed markdown blocks into project-local instruction files and a `docs/repolens-agent-setup.md` guide. For VS Code it also writes a project-local `.vscode/mcp.json` `servers.repolens` entry while preserving unrelated servers. Add `--with-hooks` to generate opt-in, non-blocking hook/reminder files plus `docs/repolens-agent-hooks.md`; these files tell agents when to call RepoLens before broad searches or risky edits, but they do not execute code by themselves. `uninstall-agents --with-hooks` removes those managed reminder files alongside managed RepoLens markdown blocks and managed VS Code config entries while preserving hand-written content. The guide includes MCP config snippets for Codex, Claude, Gemini, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro. +`install-agents` writes managed markdown blocks into project-local instruction files and a `docs/repolens-agent-setup.md` guide. For VS Code it also writes a project-local `.vscode/mcp.json` `servers.repolens` entry while preserving unrelated servers. Add `--with-hooks` to generate opt-in, non-blocking hook/reminder files plus `docs/repolens-agent-hooks.md`; for Claude Code it also merges a managed `.claude/settings.local.json` PreToolUse hook entry while preserving unrelated hooks and settings. These files tell agents when to call RepoLens before broad searches or risky edits and include an executable `hook-augment --claude` command for agents that pass hook payload JSON through stdin. `agent-hook`/`hook-augment` recognizes PreToolUse-style Grep, Glob, and broad shell search payloads, emits either text, JSON, or Claude-compatible `hookSpecificOutput.additionalContext`, exits successfully, and does not intercept Read/Edit/Write tools. The hook does not query or mutate the local graph by default; add `--with-query` only when you want it to open the RepoLens database and append symbol metadata matches. `uninstall-agents --with-hooks` removes those managed reminder files and the managed Claude hook entry alongside managed RepoLens markdown blocks and managed VS Code config entries while preserving hand-written content. The guide includes MCP config snippets for Codex, Claude, Gemini, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro. ```json { diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md index 8058833..fca3b2c 100644 --- a/docs/BENCHMARK.md +++ b/docs/BENCHMARK.md @@ -14,7 +14,7 @@ npm run test:skip-gate Latest result: - TypeScript build passed. -- Node test suite passed: 57 tests, 56 passing, 0 failures, 1 sandbox-only dashboard socket skip. +- Node test suite passed: 73 tests, 72 passing, 0 failures, 1 sandbox-only dashboard socket skip. - Test skip gate passed with explicit policies for the dashboard sandbox socket skip and git-unavailable skips. - Coverage includes indexing, incremental refresh, git-aware watch refresh, MCP startup auto-index and auto-sync wiring, project catalog and fleet summaries, graph package import/export, code search, symbol/reference lookup, semantic and vector search, context packs, graph queries, dependency cycles, git-history hotspots, change impact, secret scanning, agent setup, Codex config safeguards, package bootstrap, installer metadata, and MCP JSON-RPC robustness. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 6edb52d..0149ae9 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -24,6 +24,14 @@ node --experimental-sqlite dist/src/cli.js doctor node --experimental-sqlite dist/src/cli.js install-codex --dry-run ``` +For hook-capable agents, RepoLens can emit non-blocking broad-search context reminders from hook payload JSON: + +```bash +node --experimental-sqlite dist/src/cli.js hook-augment --db .repolens/memory.db --claude +``` + +Use this for PreToolUse-style Grep, Glob, or broad shell-search hooks. It exits successfully when RepoLens is unavailable and does not intercept Read/Edit/Write tools. By default it only parses stdin and emits guidance; add `--with-query` when you want it to open the local RepoLens database and append symbol metadata matches. `install-agents --with-hooks --agents claude` can merge this as an exec-form command hook into `.claude/settings.local.json` while preserving unrelated local hooks. + On Windows PowerShell, the local installer mirrors the shell installer: ```powershell diff --git a/docs/research-notes.md b/docs/research-notes.md index 57206a8..4ac8842 100644 --- a/docs/research-notes.md +++ b/docs/research-notes.md @@ -33,6 +33,7 @@ RepoLens MCP is not a fork or a drop-in static C replacement. It is an original - Self-contained graph and architecture report exports for sharing HTML or Markdown artifacts without running a server, plus compressed checksummed `.rlgz` graph packages for reusing a SQLite graph without reindexing. A successful index can write a fresh package with `--write-package`, and a missing database can bootstrap from `.repolens/graph.rlgz` before the incremental pass. - CI runs explicit test-skip governance, type-check, tests, production dependency audit, package dry-run, package contents gating, installer dry-run auditing, CycloneDX SBOM generation, self-indexing, and architecture output; separate workflows cover OpenSSF Scorecard and release build-provenance attestations. - `llms.txt`, `docs/agent-guide.md`, and `docs/BENCHMARK.md` provide concise agent-facing operating instructions, sanitized validation evidence, and local-data boundaries in the npm package. +- Executable `agent-hook` / `hook-augment` support turns broad-search hook payloads into non-blocking RepoLens context reminders while skipping direct Read/Edit/Write tools; `--with-query` can opt in to local graph metadata matches when the maintainer wants DB-backed augmentation. Claude Code setup can also merge a managed local PreToolUse hook entry using exec-form `command` plus `args`, avoiding shell parsing and preserving unrelated hooks. - `install.ps1` mirrors the Unix installer for Windows users, and `scripts/github-security-summary.mjs` gives maintainers a repeatable GitHub Security tab summary that separates actionable alerts from Scorecard process signals. - The release workflow separates unprivileged verify/package work from privileged attestation, GitHub release, and npm publish work. diff --git a/docs/validation-report.md b/docs/validation-report.md index 544c31a..970746d 100644 --- a/docs/validation-report.md +++ b/docs/validation-report.md @@ -21,7 +21,7 @@ npm run test:skip-gate Result: - TypeScript build passed. -- Node test suite passed: 57 tests, 56 passing, 0 failures, 1 sandbox-only dashboard socket skip. +- Node test suite passed: 73 tests, 72 passing, 0 failures, 1 sandbox-only dashboard socket skip. - Test skip gate passed with explicit policies for the dashboard sandbox socket skip and git-unavailable skips. - Covered multi-agent MCP setup rendering/dry-run/write/uninstall behavior, version/update status with npm-compatible registry checks, persistent config list/get/set/reset behavior, Codex MCP config rendering/install/uninstall safeguards including forced replacement of old unmanaged sections, project catalog list/status/delete behavior, fleet summary aggregation with inferred service links, cross-repo fleet graph generation, concurrent catalog writes, decision persistence, repository indexing, benchmark full/no-op incremental evidence, incremental refresh, removed-file pruning, watch-mode refresh, git-aware watch skipping unchanged polls and refreshing dirty worktrees, MCP startup auto-indexing and git-aware auto-sync wiring from env and persisted config, MCP stdio JSON-RPC initialization, tool listing, and invalid tool-call rejection under bounded fuzzing, graph package bootstrap from `.repolens/graph.rlgz`, index-writer locking, graph package export/import, index-time graph package writing with `--write-package`, Swift extraction, Next.js App Router route extraction, GraphQL/protobuf/tRPC/OpenAPI protocol extraction, import-resolved file edge extraction with aliases/workspace packages/relative imports, typed `INHERITS`/`IMPLEMENTS`/`USES_TYPE` relationship extraction, conservative `DATA_FLOWS` extraction, positional argument-to-parameter mapping, ambiguous callee suppression, stale data-flow edge pruning on incremental refresh, trace modes for calls/data-flow/cross-service edges, multi-ecosystem manifest extraction, package-manager lockfile extraction, Dockerfile/Kubernetes/Kustomize graph extraction, channel/event graph extraction with `EMITS` and `LISTENS_ON`, runtime trace ingestion with `OBSERVED_*` edges, symbol search, indexed reference lookup, BM25 code search with camelCase/snake_case token expansion, redacted secret scanning, semantic search, local vector search, context-pack assembly, first-class `http_call` nodes with `CALLS_HTTP_ENDPOINT`, generated `HTTP_CALLS` route-call edges, graph community detection, source snippets, graph schema including relationship patterns and label property hints, structural graph search, read-only Cypher-like graph queries including `DISTINCT`, `count`, `ORDER BY`, `SKIP`, `IN`, and numeric comparisons, relative and workspace-package import cycle resolution, git-history hotspot extraction, history-aware architecture recommendations, architecture recommendations, dead-code candidates, architecture summary, property-based resolver fuzzing, and trace behavior on fixture repositories. @@ -54,15 +54,16 @@ Result: - Production dependency audit passed with `npm run audit:prod`: 0 vulnerabilities. - Package dry run passed for `repolens-mcp@1.0.0`. -- Packed artifact: `repolens-mcp-1.0.0.tgz`, 183,403 bytes packed, 938,818 bytes unpacked, 86 runtime/doc entries. +- Packed artifact: `repolens-mcp-1.0.0.tgz`, 200,896 bytes packed, 1,029,092 bytes unpacked, 92 runtime/doc entries. - Package contents are scoped to `dist/src`, `README.md`, `LICENSE`, `SECURITY.md`, `CONTRIBUTING.md`, selected public docs, `llms.txt`, scripts, `package.json`, `server.json`, `install.sh`, and `install.ps1`; compiled tests, source TypeScript, local graph memory, SQLite databases, graph packages, fixtures, private validation output, and local workstation paths are excluded. -- Package contents gate passed: 86 files inspected. +- Package contents gate passed: 92 files inspected. - Installer audit passed for `install.sh` dry-run setup under a temporary home and target directory. `install.ps1` dry-run audit is enforced when `pwsh` is available and in CI. - CycloneDX SBOM generation passed with `npm sbom --sbom-format cyclonedx --json`. - Local installer syntax check passed for `install.sh`; the script verifies Node 24, runs `npm ci`, builds the project, runs `doctor`, can apply `install-codex` with `--dry-run`/`--force` controls, and can render or write project-local setup guidance through `install-agents`. - PowerShell installer parser check is enforced in CI for `install.ps1`; it mirrors the Unix installer's Node 24 check, npm/build flow, doctor command, Codex install/uninstall, agent install/uninstall, `-DryRun`, `-Force`, `-Db`, `-Agents`, `-Target`, and `-SkipNpm` options. Local macOS validation could not execute `pwsh` because it is not installed in this environment. - GitHub security summary script reported 0 actionable open alerts, 0 CodeQL alerts, 0 Dependabot alerts, 0 secret-scanning alerts, 3 OpenSSF Scorecard process signals, and 0 other code-scanning alerts. - `agent-setup` dry-run rendered the expected guide and instruction targets for Codex, Claude, and Gemini without writing files. +- `hook-augment --claude` smoke test emitted Claude-compatible `hookSpecificOutput.additionalContext` for a fake Grep PreToolUse payload without querying the local graph by default; the opt-in `--with-query` smoke on the fixture graph appended local symbol metadata matches, a fake Read payload exited 0 with no blocking output, and Claude hook setup tests verified managed `.claude/settings.local.json` install/update/uninstall behavior while preserving unrelated hooks. - `config set/get/reset` persisted startup defaults in an isolated temp config file and removed the managed key cleanly. - `uninstall-codex --dry-run` detected the managed Codex block without writing, and `uninstall-agents` removed generated managed blocks from a temporary project target. - `benchmark` on the fixture repository ran a full index plus no-op incremental index, returned graph totals and throughput, and reported 0 medium/high secret findings. diff --git a/package.json b/package.json index 50b88f4..dd32835 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "repolens-mcp", "version": "1.0.0", - "description": "Local-first repository intelligence MCP server with multi-agent setup, persistent config, project catalog, fleet summaries, cross-repo graphing, graph package bootstrap, optional startup auto-indexing, git-aware MCP auto-sync, incremental indexing, BM25 code search, reference lookup, typed relationship and data-flow edges, local vector search, redacted secret scanning, context packs, runtime trace ingestion, import-resolved file graphs, multi-ecosystem manifest and lockfile parsing, Docker/Kubernetes graph indexing, channel/event graph edges, git-history hotspots, watch mode, graph search, graph communities, semantic search, route-call links, read-only graph queries, source snippets, dependency-cycle checks, architecture reports, graph packages, ADR memory, graph export, and a dashboard.", + "description": "Local-first repository intelligence MCP server with multi-agent setup, executable agent hook reminders, persistent config, project catalog, fleet summaries, cross-repo graphing, graph package bootstrap, optional startup auto-indexing, git-aware MCP auto-sync, incremental indexing, BM25 code search, reference lookup, typed relationship and data-flow edges, local vector search, redacted secret scanning, context packs, runtime trace ingestion, import-resolved file graphs, multi-ecosystem manifest and lockfile parsing, Docker/Kubernetes graph indexing, channel/event graph edges, git-history hotspots, watch mode, graph search, graph communities, semantic search, route-call links, read-only graph queries, source snippets, dependency-cycle checks, architecture reports, graph packages, ADR memory, graph export, and a dashboard.", "type": "module", "mcpName": "io.github.sameer2191/repolens-mcp", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 51c192d..7b43a17 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,7 @@ import { unpackGraph, vectorSearch } from "./core/api.js"; +import { evaluateAgentHookInput, renderAgentHookResult } from "./core/agent-hooks.js"; import { agentProfiles, installAgentSetup, uninstallAgentSetup, type AgentId } from "./core/agents.js"; import type { ReportFormat } from "./core/report.js"; import type { TraceDirection, TraceMode } from "./core/types.js"; @@ -59,6 +60,9 @@ interface ParsedArgs { flags: Map; } +const AGENT_HOOK_MAX_STDIN_BYTES = 64 * 1024; +const AGENT_HOOK_STDIN_TIMEOUT_MS = 1000; + async function main(): Promise { const args = parseArgs(process.argv.slice(2)); switch (args.command) { @@ -390,6 +394,28 @@ async function main(): Promise { }) ); break; + case "agent-hook": + case "hook-augment": { + const rawInput = await readStdinText(); + const dbPath = stringFlag(args, "db"); + const includeGraphMatches = (booleanFlag(args, "with-query") || booleanFlag(args, "with-graph")) && !booleanFlag(args, "no-query"); + const result = enrichAgentHookResult( + evaluateAgentHookInput(rawInput, { + serverName: stringFlag(args, "name") ?? "repolens", + dbPath, + command: stringFlag(args, "command") ?? process.execPath, + cliPath: stringFlag(args, "cli") ?? currentCliPath() + }), + dbPath, + includeGraphMatches + ); + const format = booleanFlag(args, "json") ? "json" : booleanFlag(args, "claude") ? "claude-json" : "text"; + const rendered = renderAgentHookResult(result, format); + if (rendered) { + process.stdout.write(`${rendered}\n`); + } + break; + } case "mcp": await startMcpServer(); break; @@ -411,6 +437,35 @@ async function main(): Promise { } } +function enrichAgentHookResult(result: T, dbPath: string | undefined, enabled: boolean): T { + if (!enabled || !result.shouldRemind || !result.query || !result.message) { + return result; + } + try { + const pack = contextPack(result.query, 3, 1, dbPath); + const seen = new Set(); + const symbols = [...pack.graph, ...pack.semantic, ...pack.vector] + .map((match) => match.symbol) + .filter((symbol) => { + if (seen.has(symbol.qualifiedName)) { + return false; + } + seen.add(symbol.qualifiedName); + return true; + }) + .slice(0, 3); + if (symbols.length === 0) { + return result; + } + return { + ...result, + message: `${result.message}\nRepoLens graph matches:\n${symbols.map((symbol) => `- ${symbol.kind} ${symbol.name} (${symbol.filePath}:${symbol.startLine})`).join("\n")}` + }; + } catch { + return result; + } +} + function parseArgs(argv: string[]): ParsedArgs { const [command = "", ...rest] = argv; const positional: string[] = []; @@ -594,6 +649,47 @@ async function readTraceInput(input: string) { throw new Error("Trace input must be a JSON array or an object with a traces array."); } +async function readStdinText(): Promise { + if (process.stdin.isTTY) { + return ""; + } + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + process.stdin.off("data", onData); + process.stdin.off("end", finish); + process.stdin.off("error", finish); + process.stdin.pause(); + resolve(Buffer.concat(chunks).toString("utf8")); + }; + const onData = (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = AGENT_HOOK_MAX_STDIN_BYTES - total; + if (remaining <= 0) { + finish(); + return; + } + chunks.push(buffer.length > remaining ? buffer.subarray(0, remaining) : buffer); + total += Math.min(buffer.length, remaining); + if (buffer.length >= remaining) { + finish(); + } + }; + const timer = setTimeout(finish, AGENT_HOOK_STDIN_TIMEOUT_MS); + process.stdin.on("data", onData); + process.stdin.once("end", finish); + process.stdin.once("error", finish); + process.stdin.resume(); + }); +} + function currentCliPath(): string { return path.resolve(process.argv[1] ?? "repolens-mcp"); } @@ -667,6 +763,7 @@ Usage: repolens-mcp agent-setup [--target .] [--agents all|codex,claude,gemini,zed,opencode,antigravity,aider,kilocode,vscode,openclaw,kiro] [--db .repolens/memory.db] [--with-hooks] repolens-mcp install-agents [--target .] [--agents all|codex,claude,gemini,zed,opencode,antigravity,aider,kilocode,vscode,openclaw,kiro] [--dry-run] [--with-hooks] repolens-mcp uninstall-agents [--target .] [--agents all|codex,claude,gemini,zed,opencode,antigravity,aider,kilocode,vscode,openclaw,kiro] [--dry-run] [--with-hooks] + repolens-mcp agent-hook|hook-augment [--db .repolens/memory.db] [--name repolens] [--json|--claude] [--with-query] repolens-mcp mcp repolens-mcp demo `; diff --git a/src/core/agent-hooks.ts b/src/core/agent-hooks.ts new file mode 100644 index 0000000..dd7de81 --- /dev/null +++ b/src/core/agent-hooks.ts @@ -0,0 +1,176 @@ +export interface AgentHookOptions { + serverName?: string; + dbPath?: string; + command?: string; + cliPath?: string; +} + +export interface AgentHookResult { + shouldRemind: boolean; + reason: string; + eventName?: string; + toolName?: string; + query?: string; + mcpTools: string[]; + fallbackCommand?: string; + message?: string; +} + +export type AgentHookRenderFormat = "text" | "json" | "claude-json"; + +const BROAD_SEARCH_TOOLS = new Set(["bash", "grep", "glob", "search", "search_code"]); +const SKIPPED_TOOLS = new Set(["read", "edit", "multiedit", "write", "notebookedit"]); +const BROAD_SHELL_PATTERN = /(^|\s)(rg|grep|ag|find|fd|ack)\b/i; + +export function evaluateAgentHookInput(rawInput: string, options: AgentHookOptions = {}): AgentHookResult { + const payload = parseHookPayload(rawInput); + if (!payload) { + return noReminder("No hook payload JSON was provided."); + } + return evaluateAgentHookPayload(payload, options); +} + +export function evaluateAgentHookPayload(payload: unknown, options: AgentHookOptions = {}): AgentHookResult { + if (!isRecord(payload)) { + return noReminder("Hook payload must be a JSON object."); + } + + const eventName = stringValue(payload.hook_event_name) ?? stringValue(payload.hookEventName) ?? stringValue(payload.event); + if (eventName && eventName !== "PreToolUse") { + return noReminder(`Ignoring ${eventName} hook event.`, eventName); + } + + const toolName = stringValue(payload.tool_name) ?? stringValue(payload.toolName) ?? stringValue(payload.name); + if (!toolName) { + return noReminder("Hook payload did not include a tool name.", eventName); + } + + const normalizedTool = toolName.toLowerCase(); + if (SKIPPED_TOOLS.has(normalizedTool)) { + return noReminder(`Ignoring ${toolName}; direct file read and edit tools are not intercepted.`, eventName, toolName); + } + + const toolInput = recordValue(payload.tool_input) ?? recordValue(payload.toolInput) ?? recordValue(payload.input) ?? {}; + const query = queryForTool(normalizedTool, toolInput); + if (!query) { + return noReminder(`Ignoring ${toolName}; no broad search query was detected.`, eventName, toolName); + } + + const serverName = options.serverName ?? "repolens"; + const dbPath = options.dbPath ?? ".repolens/memory.db"; + const command = options.command ?? "repolens-mcp"; + const cliPath = options.cliPath; + const mcpTools = [`${serverName}.context_pack`, `${serverName}.search_graph`, `${serverName}.get_graph_schema`]; + const fallbackParts = cliPath + ? [command, "--experimental-sqlite", cliPath, "context-pack", query, "--db", dbPath] + : [command, "context-pack", query, "--db", dbPath]; + const fallbackCommand = shellJoin(fallbackParts); + const message = [ + `RepoLens context reminder for ${toolName}: ask ${mcpTools[0]} or ${mcpTools[1]} about ${JSON.stringify(query)} before broad search.`, + `Fallback command: ${fallbackCommand}`, + "Non-blocking: continue normally if RepoLens is unavailable." + ].join("\n"); + + return { + shouldRemind: true, + reason: "Broad search-like tool call detected.", + eventName, + toolName, + query, + mcpTools, + fallbackCommand, + message + }; +} + +export function renderAgentHookResult(result: AgentHookResult, format: AgentHookRenderFormat = "text"): string { + if (format === "json") { + return JSON.stringify(result, null, 2); + } + if (format === "claude-json") { + return result.shouldRemind && result.message + ? JSON.stringify( + { + hookSpecificOutput: { + hookEventName: result.eventName ?? "PreToolUse", + additionalContext: result.message + } + }, + null, + 2 + ) + : ""; + } + return result.message ?? ""; +} + +function queryForTool(toolName: string, input: Record): string | undefined { + if (toolName === "bash") { + const command = stringValue(input.command); + if (!command || !BROAD_SHELL_PATTERN.test(command)) { + return undefined; + } + return cleanQuery(command); + } + if (!BROAD_SEARCH_TOOLS.has(toolName)) { + return undefined; + } + return cleanQuery( + stringValue(input.pattern) ?? + stringValue(input.query) ?? + stringValue(input.regex) ?? + stringValue(input.glob) ?? + stringValue(input.path) ?? + stringValue(input.command) + ); +} + +function parseHookPayload(rawInput: string): unknown { + const trimmed = rawInput.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + return null; + } +} + +function noReminder(reason: string, eventName?: string, toolName?: string): AgentHookResult { + return { + shouldRemind: false, + reason, + eventName, + toolName, + mcpTools: [] + }; +} + +function cleanQuery(value: string | undefined): string | undefined { + const query = value?.replace(/\s+/g, " ").trim(); + if (!query) { + return undefined; + } + return query.length > 160 ? `${query.slice(0, 157)}...` : query; +} + +function recordValue(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function shellJoin(parts: string[]): string { + return parts.map(posixShellQuote).join(" "); +} + +function posixShellQuote(part: string): string { + return /^[A-Za-z0-9_./:=@-]+$/.test(part) ? part : `'${part.replace(/'/g, "'\\''")}'`; +} diff --git a/src/core/agents.ts b/src/core/agents.ts index bf8060a..8f1cf69 100644 --- a/src/core/agents.ts +++ b/src/core/agents.ts @@ -21,6 +21,8 @@ export interface AgentProfile { instructionPath: string; configPath?: string; configKind?: "vscode-mcp"; + hookConfigPath?: string; + hookConfigKind?: "claude-hooks"; hookPath?: string; } @@ -54,10 +56,20 @@ export interface AgentSetupResult { const MANAGED_START = ""; const MANAGED_END = ""; +const CLAUDE_HOOK_STATUS = "RepoLens context reminder (managed by repolens-mcp)"; +const CLAUDE_HOOK_MATCHER = "Grep|Glob|Bash"; export const agentProfiles: AgentProfile[] = [ { id: "codex", label: "Codex CLI", configHint: ".codex/config.toml", instructionPath: ".codex/AGENTS.md", hookPath: ".codex/repolens-hooks.md" }, - { id: "claude", label: "Claude Code", configHint: ".mcp.json or ~/.claude/.mcp.json", instructionPath: "CLAUDE.md", hookPath: ".claude/repolens-hooks.md" }, + { + id: "claude", + label: "Claude Code", + configHint: ".mcp.json or ~/.claude/.mcp.json", + instructionPath: "CLAUDE.md", + hookPath: ".claude/repolens-hooks.md", + hookConfigPath: ".claude/settings.local.json", + hookConfigKind: "claude-hooks" + }, { id: "gemini", label: "Gemini CLI", configHint: ".gemini/settings.json", instructionPath: ".gemini/GEMINI.md", hookPath: ".gemini/repolens-hooks.md" }, { id: "zed", label: "Zed", configHint: "settings.json context server", instructionPath: ".zed/repolens.md", hookPath: ".zed/repolens-hooks.md" }, { id: "opencode", label: "OpenCode", configHint: "opencode.json", instructionPath: ".opencode/AGENTS.md", hookPath: ".opencode/repolens-hooks.md" }, @@ -138,6 +150,16 @@ export async function installAgentSetup(options: AgentSetupOptions): Promise options.withHooks && item.hookConfigPath && item.hookConfigKind)) { + const outPath = path.join(targetDir, profile.hookConfigPath ?? ""); + const existing = await fs.readFile(outPath, "utf8").catch(() => ""); + const content = upsertAgentHookConfig(existing, profile, renderOptions); + written.push({ path: outPath, changed: content !== existing, content }); + if (!options.dryRun && content !== existing) { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, content); + } + } return { targetDir, @@ -188,6 +210,21 @@ export async function uninstallAgentSetup(options: Omit options.withHooks && item.hookConfigPath && item.hookConfigKind)) { + const outPath = path.join(targetDir, profile.hookConfigPath ?? ""); + const existing = await fs.readFile(outPath, "utf8").catch(() => ""); + const content = removeAgentHookConfig(existing, profile); + const removed = content.length === 0; + const changed = content !== existing; + files.push({ path: outPath, changed, content, removed }); + if (!options.dryRun && changed) { + if (removed) { + await fs.rm(outPath, { force: true }); + } else { + await fs.writeFile(outPath, content); + } + } + } return { targetDir, @@ -252,10 +289,15 @@ function agentHookGuide(options: { cliPath: string; dbPath: string; }): string { - const hookTargets = options.profiles.map((profile) => `- ${profile.label}: \`${profile.hookPath ?? profile.instructionPath}\``).join("\n"); + const hookTargets = options.profiles + .map((profile) => { + const targets = [profile.hookPath ?? profile.instructionPath, profile.hookConfigPath].filter((target): target is string => Boolean(target)); + return `- ${profile.label}: ${targets.map((target) => `\`${target}\``).join(", ")}`; + }) + .join("\n"); return `# RepoLens Agent Hook And Reminder Setup -These files are opt-in, project-local reminders for coding agents that support session prompts, hook notes, or project rules. They are intentionally non-blocking and read-only: the agent should use RepoLens context tools before broad searches, but the files do not execute code by themselves. +These files are opt-in, project-local reminders for coding agents that support session prompts, hook notes, or project rules. The reminder files do not execute code by themselves. The executable hook command below is designed to be non-blocking by default: it parses hook JSON from stdin and emits context guidance without querying or mutating the local graph unless you opt in with \`--with-query\`. Server name: \`${options.serverName}\` Database: \`${options.dbPath}\` @@ -271,6 +313,16 @@ ${hookTargets} - Before graph queries, ask \`${options.serverName}.get_graph_schema\` first. - Keep hook behavior non-blocking: if RepoLens is unavailable, continue with normal local inspection and mention the miss. +## Executable Hook Command + +For agents that can pass hook payload JSON to stdin, wire broad-search hooks to: + +\`\`\`bash +${shellJoin([options.command, "--experimental-sqlite", options.cliPath, "hook-augment", "--db", options.dbPath, "--name", options.serverName, "--claude"])} +\`\`\` + +The hook runner recognizes PreToolUse-style Grep, Glob, and broad shell search payloads. It emits Claude-compatible \`hookSpecificOutput.additionalContext\`, exits successfully, and does not intercept Read/Edit/Write tools. Add \`--with-query\` only when you want the hook to open the local RepoLens database and append symbol metadata matches. + ## Local Fallback Commands \`\`\`bash @@ -296,6 +348,12 @@ Use this as a non-blocking project reminder: - For custom graph queries, call \`${options.serverName}.get_graph_schema\` before \`${options.serverName}.query_graph\`. - If the MCP server is unavailable, continue without blocking and say that RepoLens context was unavailable. +Executable hook command for agents that pass hook payload JSON to stdin: + +\`\`\`bash +${shellJoin([options.command, "--experimental-sqlite", options.cliPath, "hook-augment", "--db", options.dbPath, "--name", options.serverName, "--claude"])} +\`\`\` + Local fallback: \`\`\`bash @@ -362,6 +420,27 @@ function removeAgentConfig(existing: string, profile: AgentProfile, serverName: } } +function upsertAgentHookConfig(existing: string, profile: AgentProfile, options: { serverName: string; command: string; cliPath: string; dbPath: string }): string { + switch (profile.hookConfigKind) { + case "claude-hooks": + return upsertClaudeHookConfig(existing, options); + default: + throw new Error(`Unsupported agent hook config writer for ${profile.id}.`); + } +} + +function removeAgentHookConfig(existing: string, profile: AgentProfile): string { + if (!existing.trim()) { + return ""; + } + switch (profile.hookConfigKind) { + case "claude-hooks": + return removeClaudeHookConfig(existing); + default: + throw new Error(`Unsupported agent hook config remover for ${profile.id}.`); + } +} + function upsertVscodeMcpConfig(existing: string, options: { serverName: string; command: string; cliPath: string; dbPath: string }): string { assertSafeServerName(options.serverName); const config = parseJsonObject(existing, ".vscode/mcp.json"); @@ -387,6 +466,81 @@ function removeVscodeMcpConfig(existing: string, serverName: string): string { return Object.keys(config).length === 0 ? "" : `${JSON.stringify(config, null, 2)}\n`; } +function upsertClaudeHookConfig(existing: string, options: { serverName: string; command: string; cliPath: string; dbPath: string }): string { + const label = ".claude/settings.local.json"; + const config = parseJsonObject(existing, label); + const hooks = jsonObjectProperty(config, "hooks", label) ?? {}; + const preToolUse = jsonArrayProperty(hooks, "PreToolUse", label) ?? []; + hooks.PreToolUse = [ + ...removeManagedClaudeHookEntries(preToolUse), + { + matcher: CLAUDE_HOOK_MATCHER, + hooks: [claudeHookCommand(options)] + } + ]; + config.hooks = hooks; + return `${JSON.stringify(config, null, 2)}\n`; +} + +function removeClaudeHookConfig(existing: string): string { + const label = ".claude/settings.local.json"; + const config = parseJsonObject(existing, label); + const hooks = jsonObjectProperty(config, "hooks", label, false); + if (!hooks) { + return existing; + } + const preToolUse = jsonArrayProperty(hooks, "PreToolUse", label, false); + if (!preToolUse) { + return existing; + } + const cleaned = removeManagedClaudeHookEntries(preToolUse); + if (cleaned.length === preToolUse.length) { + return existing; + } + if (cleaned.length === 0) { + delete hooks.PreToolUse; + } else { + hooks.PreToolUse = cleaned; + } + if (Object.keys(hooks).length === 0) { + delete config.hooks; + } else { + config.hooks = hooks; + } + return Object.keys(config).length === 0 ? "" : `${JSON.stringify(config, null, 2)}\n`; +} + +function claudeHookCommand(options: { serverName: string; command: string; cliPath: string; dbPath: string }) { + return { + type: "command", + command: options.command, + args: ["--experimental-sqlite", options.cliPath, "hook-augment", "--db", options.dbPath, "--name", options.serverName, "--claude"], + timeout: 5, + statusMessage: CLAUDE_HOOK_STATUS + }; +} + +function removeManagedClaudeHookEntries(entries: unknown[]): unknown[] { + const cleaned: unknown[] = []; + for (const entry of entries) { + if (!isJsonObject(entry) || !Array.isArray(entry.hooks)) { + cleaned.push(entry); + continue; + } + const hooks = entry.hooks.filter((hook) => !isManagedClaudeHook(hook)); + if (hooks.length === entry.hooks.length) { + cleaned.push(entry); + } else if (hooks.length > 0) { + cleaned.push({ ...entry, hooks }); + } + } + return cleaned; +} + +function isManagedClaudeHook(value: unknown): boolean { + return isJsonObject(value) && value.type === "command" && value.statusMessage === CLAUDE_HOOK_STATUS; +} + function mcpServerConfig(options: { serverName: string; command: string; cliPath: string; dbPath: string; managed?: boolean }) { const args = ["--experimental-sqlite", options.cliPath, "mcp"]; return { @@ -426,6 +580,17 @@ function jsonObjectProperty(config: Record, key: string, label: return { ...value }; } +function jsonArrayProperty(config: Record, key: string, label: string, create = true): unknown[] | undefined { + const value = config[key]; + if (value === undefined) { + return create ? [] : undefined; + } + if (!Array.isArray(value)) { + throw new Error(`${label}.${key} must be an array.`); + } + return [...value]; +} + function isManagedMcpServer(value: unknown): boolean { return isJsonObject(value) && isJsonObject(value.env) && value.env.REPOLENS_MANAGED === "1"; } @@ -496,7 +661,11 @@ function snippetLanguage(agent: AgentId): string { } function shellJoin(parts: string[]): string { - return parts.map((part) => (/^[A-Za-z0-9_./:=@-]+$/.test(part) ? part : JSON.stringify(part))).join(" "); + return parts.map(posixShellQuote).join(" "); +} + +function posixShellQuote(part: string): string { + return /^[A-Za-z0-9_./:=@-]+$/.test(part) ? part : `'${part.replace(/'/g, "'\\''")}'`; } function escapeRegExp(value: string): string { diff --git a/tests/agent-hooks.test.ts b/tests/agent-hooks.test.ts new file mode 100644 index 0000000..2ecaa68 --- /dev/null +++ b/tests/agent-hooks.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { evaluateAgentHookInput, evaluateAgentHookPayload, renderAgentHookResult } from "../src/core/agent-hooks.js"; + +test("agent hook suggests RepoLens context before broad Grep use", () => { + const result = evaluateAgentHookPayload( + { + hook_event_name: "PreToolUse", + tool_name: "Grep", + tool_input: { pattern: "createOrder", path: "src" } + }, + { serverName: "repolens", dbPath: ".repolens/memory.db", command: "node", cliPath: "/repo/dist/src/cli.js" } + ); + + assert.equal(result.shouldRemind, true); + assert.equal(result.toolName, "Grep"); + assert.equal(result.query, "createOrder"); + assert.deepEqual(result.mcpTools.slice(0, 2), ["repolens.context_pack", "repolens.search_graph"]); + assert.match(result.fallbackCommand ?? "", /context-pack createOrder --db \.repolens\/memory\.db/); + assert.match(renderAgentHookResult(result), /Non-blocking/); +}); + +test("agent hook shell-quotes fallback commands with hook-controlled queries", () => { + const result = evaluateAgentHookPayload( + { + hook_event_name: "PreToolUse", + tool_name: "Grep", + tool_input: { pattern: "$(touch /tmp/pwn); `whoami`\nnext" } + }, + { dbPath: "db $(rm).sqlite", command: "/usr/bin/node", cliPath: "/repo path/dist/src/cli.js" } + ); + + assert.equal(result.shouldRemind, true); + assert.match(result.fallbackCommand ?? "", /'\/repo path\/dist\/src\/cli\.js'/); + assert.match(result.fallbackCommand ?? "", /'\$\(touch \/tmp\/pwn\); `whoami` next'/); + assert.match(result.fallbackCommand ?? "", /'db \$\(rm\)\.sqlite'/); + assert.doesNotMatch(result.fallbackCommand ?? "", /"\$\(/); +}); + +test("agent hook detects broad shell searches", () => { + const result = evaluateAgentHookPayload({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "rg \"agent setup\" src tests" } + }); + + assert.equal(result.shouldRemind, true); + assert.equal(result.toolName, "Bash"); + assert.match(result.query ?? "", /rg/); + assert.match(result.message ?? "", /RepoLens context reminder/); +}); + +test("agent hook skips direct read and edit tools", () => { + for (const tool_name of ["Read", "Edit", "Write", "MultiEdit"]) { + const result = evaluateAgentHookPayload({ hook_event_name: "PreToolUse", tool_name, tool_input: { file_path: "src/core/store.ts" } }); + assert.equal(result.shouldRemind, false); + assert.match(result.reason, /not intercepted/); + } +}); + +test("agent hook fails open for malformed or unrelated payloads", () => { + const invalid = evaluateAgentHookInput("{not-json"); + assert.equal(invalid.shouldRemind, false); + assert.match(invalid.reason, /No hook payload/); + + const postTool = evaluateAgentHookPayload({ hook_event_name: "PostToolUse", tool_name: "Grep", tool_input: { pattern: "createOrder" } }); + assert.equal(postTool.shouldRemind, false); + + const narrowBash = evaluateAgentHookPayload({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm test" } }); + assert.equal(narrowBash.shouldRemind, false); +}); + +test("agent hook can render json for machine-readable hook logs", () => { + const result = evaluateAgentHookPayload({ tool_name: "Glob", tool_input: { pattern: "**/*.ts" } }); + const rendered = JSON.parse(renderAgentHookResult(result, "json")) as { shouldRemind: boolean; query: string }; + + assert.equal(rendered.shouldRemind, true); + assert.equal(rendered.query, "**/*.ts"); +}); + +test("agent hook can render Claude hook additional context", () => { + const result = evaluateAgentHookPayload({ + hook_event_name: "PreToolUse", + tool_name: "Grep", + tool_input: { pattern: "checkout" } + }); + const rendered = JSON.parse(renderAgentHookResult(result, "claude-json")) as { + hookSpecificOutput: { hookEventName: string; additionalContext: string }; + }; + + assert.equal(rendered.hookSpecificOutput.hookEventName, "PreToolUse"); + assert.match(rendered.hookSpecificOutput.additionalContext, /context reminder/); + assert.match(rendered.hookSpecificOutput.additionalContext, /checkout/); +}); diff --git a/tests/agent-setup.test.ts b/tests/agent-setup.test.ts index 2b62ad7..97dca32 100644 --- a/tests/agent-setup.test.ts +++ b/tests/agent-setup.test.ts @@ -49,15 +49,23 @@ test("agent setup can render opt-in hook reminder files", async () => { targetDir: tmp, agents: ["claude", "gemini"], command: "node", - cliPath: "/repo/cli.js", - dbPath: ".repolens/memory.db", + cliPath: "/repo path/cli.js", + dbPath: ".repolens/memory $(rm).db", withHooks: true, dryRun: true }); + const hookGuide = result.files.find((file) => file.path.endsWith("docs/repolens-agent-hooks.md"))?.content ?? ""; assert.equal(result.withHooks, true); assert.ok(result.files.some((file) => file.path.endsWith("docs/repolens-agent-hooks.md") && file.changed)); assert.ok(result.files.some((file) => file.path.endsWith(".claude/repolens-hooks.md") && file.content.includes("context_pack"))); + assert.ok(result.files.some((file) => file.path.endsWith(".claude/repolens-hooks.md") && file.content.includes("hook-augment"))); + assert.ok(result.files.some((file) => file.path.endsWith(".claude/repolens-hooks.md") && file.content.includes("--claude"))); + assert.ok(result.files.some((file) => file.path.endsWith("docs/repolens-agent-hooks.md") && file.content.includes("without querying or mutating"))); + assert.ok(result.files.some((file) => file.path.endsWith("docs/repolens-agent-hooks.md") && file.content.includes("--with-query"))); + assert.ok(result.files.some((file) => file.path.endsWith("docs/repolens-agent-hooks.md") && file.content.includes(".claude/settings.local.json"))); + assert.match(hookGuide, /'\/repo path\/cli\.js'/); + assert.match(hookGuide, /'\.repolens\/memory \$\(rm\)\.db'/); assert.ok(result.files.some((file) => file.path.endsWith(".gemini/repolens-hooks.md") && file.content.includes("non-blocking"))); await assert.rejects(() => fs.readFile(path.join(tmp, "docs/repolens-agent-hooks.md"), "utf8")); }); @@ -86,6 +94,83 @@ test("agent setup uninstall removes managed hook reminders when requested", asyn await assert.rejects(() => fs.readFile(hookPath, "utf8")); }); +test("agent setup installs and removes managed Claude hook settings", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "repolens-claude-hooks-")); + const settingsPath = path.join(tmp, ".claude", "settings.local.json"); + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.writeFile( + settingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo", args: ["keep"], statusMessage: "Keep this hook" }] + } + ], + Stop: [ + { + hooks: [{ type: "command", command: "echo", args: ["done"] }] + } + ] + } + }, + null, + 2 + ) + ); + + const dryRun = await installAgentSetup({ + targetDir: tmp, + agents: ["claude"], + command: "node", + cliPath: "/repo/one.js", + dbPath: ".repolens/memory.db", + withHooks: true, + dryRun: true + }); + assert.ok(dryRun.files.some((file) => file.path.endsWith(".claude/settings.local.json") && file.changed)); + assert.ok(!(await fs.readFile(settingsPath, "utf8")).includes("hook-augment")); + + await installAgentSetup({ + targetDir: tmp, + agents: ["claude"], + command: "node", + cliPath: "/repo/one.js", + dbPath: ".repolens/memory.db", + withHooks: true + }); + await installAgentSetup({ + targetDir: tmp, + agents: ["claude"], + command: "node", + cliPath: "/repo/two.js", + dbPath: ".repolens/memory.db", + withHooks: true + }); + + const installed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { + hooks: { PreToolUse: Array<{ matcher?: string; hooks?: Array<{ command?: string; args?: string[]; statusMessage?: string }> }>; Stop: unknown[] }; + }; + const managedHooks = installed.hooks.PreToolUse.flatMap((entry) => entry.hooks ?? []).filter((hook) => hook.statusMessage?.includes("repolens-mcp")); + assert.equal(managedHooks.length, 1); + assert.equal(installed.hooks.PreToolUse.some((entry) => entry.matcher === "Bash"), true); + assert.equal(installed.hooks.Stop.length, 1); + assert.equal(managedHooks[0]?.command, "node"); + assert.deepEqual(managedHooks[0]?.args, ["--experimental-sqlite", "/repo/two.js", "hook-augment", "--db", ".repolens/memory.db", "--name", "repolens", "--claude"]); + + const result = await uninstallAgentSetup({ + targetDir: tmp, + agents: ["claude"], + withHooks: true + }); + const uninstalled = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { hooks: { PreToolUse: Array<{ hooks?: Array<{ statusMessage?: string }> }>; Stop: unknown[] } }; + assert.ok(result.files.some((file) => file.path.endsWith(".claude/settings.local.json") && file.changed && !file.removed)); + assert.equal(uninstalled.hooks.PreToolUse.flatMap((entry) => entry.hooks ?? []).some((hook) => hook.statusMessage?.includes("repolens-mcp")), false); + assert.equal(uninstalled.hooks.Stop.length, 1); +}); + test("agent setup writes and replaces managed instruction blocks", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "repolens-agents-")); const guidePath = path.join(tmp, "docs", "repolens-agent-setup.md");