diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..cef29f478 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,42 @@ +{ + "hooks": { + "afterFileEdit": [ + { + "command": "'/home/zack/projects/tokensave/target/debug/tokensave' hook-cursor-after-file-edit", + "matcher": "Write", + "timeout": 30 + } + ], + "afterShellExecution": [ + { + "command": "'/home/zack/projects/tokensave/target/debug/tokensave' hook-cursor-after-shell", + "timeout": 60 + } + ], + "beforeSubmitPrompt": [ + { + "command": "'/home/zack/projects/tokensave/target/debug/tokensave' hook-cursor-before-submit-prompt", + "timeout": 5 + } + ], + "sessionStart": [ + { + "command": "'/home/zack/projects/tokensave/target/debug/tokensave' hook-cursor-session-start", + "timeout": 5 + } + ], + "subagentStart": [ + { + "command": "'/home/zack/projects/tokensave/target/debug/tokensave' hook-cursor-subagent-start", + "timeout": 5 + } + ], + "workspaceOpen": [ + { + "command": "'/home/zack/projects/tokensave/target/debug/tokensave' hook-cursor-workspace-open", + "timeout": 60 + } + ] + }, + "version": 1 +} diff --git a/.cursor/mcp.json b/.cursor/mcp.json index f46aabea4..f116fa341 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -4,7 +4,8 @@ "args": [ "serve" ], - "command": "/home/zack/projects/tokensave/target/debug/tokensave" + "command": "/home/zack/projects/tokensave/target/debug/tokensave", + "type": "stdio" } } } diff --git a/.cursor/permissions.json b/.cursor/permissions.json new file mode 100644 index 000000000..74f3b1d3f --- /dev/null +++ b/.cursor/permissions.json @@ -0,0 +1,69 @@ +{ + "mcpAllowlist": [ + "tokensave:tokensave_search", + "tokensave:tokensave_context", + "tokensave:tokensave_callers", + "tokensave:tokensave_callees", + "tokensave:tokensave_impact", + "tokensave:tokensave_node", + "tokensave:tokensave_status", + "tokensave:tokensave_files", + "tokensave:tokensave_affected", + "tokensave:tokensave_dead_code", + "tokensave:tokensave_diff_context", + "tokensave:tokensave_module_api", + "tokensave:tokensave_circular", + "tokensave:tokensave_hotspots", + "tokensave:tokensave_similar", + "tokensave:tokensave_rename_preview", + "tokensave:tokensave_unused_imports", + "tokensave:tokensave_rank", + "tokensave:tokensave_largest", + "tokensave:tokensave_coupling", + "tokensave:tokensave_inheritance_depth", + "tokensave:tokensave_distribution", + "tokensave:tokensave_recursion", + "tokensave:tokensave_complexity", + "tokensave:tokensave_doc_coverage", + "tokensave:tokensave_god_class", + "tokensave:tokensave_changelog", + "tokensave:tokensave_port_status", + "tokensave:tokensave_port_order", + "tokensave:tokensave_commit_context", + "tokensave:tokensave_pr_context", + "tokensave:tokensave_simplify_scan", + "tokensave:tokensave_test_map", + "tokensave:tokensave_type_hierarchy", + "tokensave:tokensave_branch_search", + "tokensave:tokensave_branch_diff", + "tokensave:tokensave_branch_list", + "tokensave:tokensave_gini", + "tokensave:tokensave_dependency_depth", + "tokensave:tokensave_health", + "tokensave:tokensave_redundancy", + "tokensave:tokensave_runtime", + "tokensave:tokensave_dsm", + "tokensave:tokensave_test_risk", + "tokensave:tokensave_body", + "tokensave:tokensave_todos", + "tokensave:tokensave_callers_for", + "tokensave:tokensave_by_qualified_name", + "tokensave:tokensave_signature", + "tokensave:tokensave_impls", + "tokensave:tokensave_diagnose", + "tokensave:tokensave_derives", + "tokensave:tokensave_session_recall", + "tokensave:tokensave_read", + "tokensave:tokensave_outline", + "tokensave:tokensave_implementations", + "tokensave:tokensave_unsafe_patterns", + "tokensave:tokensave_diagnostics", + "tokensave:tokensave_config", + "tokensave:tokensave_signature_search", + "tokensave:tokensave_constructors", + "tokensave:tokensave_field_sites", + "tokensave:tokensave_call_chain", + "tokensave:tokensave_file_dependents", + "tokensave:tokensave_find_exact_symbol" + ] +} diff --git a/.cursor/rules/tokensave.mdc b/.cursor/rules/tokensave.mdc new file mode 100644 index 000000000..7883c911d --- /dev/null +++ b/.cursor/rules/tokensave.mdc @@ -0,0 +1,10 @@ +--- +description: Prefer tokensave MCP tools for codebase exploration +alwaysApply: true +--- + +# Prefer tokensave MCP tools + +- For codebase exploration, symbol lookup, call graphs, callers/callees, impact analysis, affected files, and architectural navigation, use the tokensave MCP tools first. +- Prefer tools such as `tokensave_context`, `tokensave_search`, `tokensave_callers`, `tokensave_callees`, `tokensave_impact`, `tokensave_files`, `tokensave_affected`, and related read-only tokensave tools before broad file reads or search. +- Only fall back to regular file reads, search, or shell commands when tokensave cannot answer the question or after tokensave has identified the exact files or symbols to inspect. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af5b3b4b8..ce41d0630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [master, feature/holographic-memory] pull_request: - branches: [master, feature/holographic-memory] + branches: ['**'] permissions: contents: read @@ -15,6 +15,7 @@ env: jobs: test: name: Test ${{ matrix.name }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }} runs-on: ${{ fromJSON(matrix.runner) }} strategy: fail-fast: false @@ -41,6 +42,7 @@ jobs: clippy: name: Clippy + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }} runs-on: ubuntu-latest continue-on-error: true steps: @@ -53,6 +55,7 @@ jobs: fmt: name: Format + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index bcb7dda3e..e11a895a4 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ tokensave install --agent vibe # Mistral Vibe tokensave install --agent zed # Zed ``` -Each agent gets its MCP server registered in the native config format. Claude Code additionally gets a PreToolUse hook (blocks wasteful Explore agents), a UserPromptSubmit hook, a Stop hook, prompt rules in CLAUDE.md, and auto-allowed tool permissions. Kiro gets global MCP config, `tokensave.md` steering loaded as a resource, and a tokensave-managed default agent with permissive built-in/tokensave tool approval, delegation guardrail hooks, and post-write sync; user-managed Kiro agents are preserved. +Each agent gets its MCP server registered in the native config format. Claude Code additionally gets a PreToolUse hook (blocks wasteful Explore agents), a UserPromptSubmit hook, a Stop hook, prompt rules in CLAUDE.md, and auto-allowed tool permissions. Kiro gets global MCP config, `tokensave.md` steering loaded as a resource, and a tokensave-managed default agent with permissive built-in/tokensave tool approval, delegation guardrail hooks, and post-write sync; user-managed Kiro agents are preserved. Cursor global install currently registers the MCP server only; the richer Cursor integration is project-local so it can be checked into a repository. All changes are idempotent -- safe to run again after upgrading. After agent setup, you'll be offered a global git post-commit hook. @@ -145,7 +145,18 @@ For project-scoped setup, run from the repository root: tokensave install --local --agent cursor ``` -Local install writes only workspace files such as `.cursor/mcp.json`, `.mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, or the equivalent project config for Claude, Codex, Gemini, Kiro, OpenCode, Copilot/VS Code, Zed, Roo Code, Kimi, Kilo, and Vibe. Generated MCP configs use the resolved absolute `tokensave` executable path. Local install does not update `~/.tokensave/config.toml`, installed-agent tracking, the last installed version, or the global git post-commit hook. Antigravity and Cline are global-only and return clear unsupported errors for `--local`. +Local install writes only workspace files such as `.cursor/mcp.json`, `.mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, or the equivalent project config for Claude, Codex, Gemini, Kiro, OpenCode, Copilot/VS Code, Zed, Roo Code, Kimi, Kilo, and Vibe. Generated MCP configs use the resolved absolute `tokensave` executable path. For Cursor, local install also writes `.cursor/rules/tokensave.mdc`, `.cursor/permissions.json`, and `.cursor/hooks.json`: the rule tells Cursor Agent to prefer tokensave MCP tools for codebase exploration, and permissions auto-allow only read-only tokensave MCP tools. The project hooks are: + +- `sessionStart` — fire-and-forget; injects context steering the Agent toward tokensave MCP tools and reports index freshness (suggests `tokensave init` when no `.tokensave/` exists). +- `subagentStart` — blocks research/explore subagents until tokensave MCP tools have been tried. +- `beforeSubmitPrompt` — resets the local token counter for the new turn. +- `afterFileEdit` (matcher `Write`) — runs a **targeted single-file** sync of just the edited path(s) via `sync_if_stale_silent`, never a full-tree scan (which would scale with repo size, not edit size). +- `afterShellExecution` — on Agent-run `git checkout`/`switch`/`worktree add`, bootstraps/maintains tokensave branch tracking (`branch add`); on other state-changing git commands (pull/merge/rebase/reset/cherry-pick/stash apply|pop), runs a coalesced incremental sync. +- `workspaceOpen` — ensures the current branch's DB exists (branch add if missing) and runs a catch-up incremental sync. + +All Cursor hooks are fail-open and only act when a `.tokensave/` index already exists. **Blind spot:** Cursor hooks only observe the Cursor Agent's own actions and IDE lifecycle. Manual/external-terminal `git checkout` and in-place branch switches are NOT seen by these hooks (`workspaceOpen` does not fire for an in-place checkout). For those, the git post-commit hook and the on-demand MCP staleness check remain the freshness mechanism. We intentionally do not add `beforeReadFile`/`preToolUse` blocking hooks here (too aggressive/noisy); they may become opt-in later. + +Local install does not update `~/.tokensave/config.toml`, installed-agent tracking, the last installed version, or the global git post-commit hook. Antigravity and Cline are global-only and return clear unsupported errors for `--local`. ### 3. Index your project diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index e6fb94c77..ad27916d6 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -200,7 +200,7 @@ tokensave install --agent kimi # Moonshot Kimi CLI tokensave install --agent vibe # Mistral Vibe ``` -Each agent gets an appropriate configuration: MCP server registration, tool permissions (where the agent supports them), and prompt rules in the agent's instruction file. +Each agent gets an appropriate configuration: MCP server registration, tool permissions (where the agent supports them), and prompt rules in the agent's instruction file. Cursor's global install currently registers the MCP server only; use project-local install for Cursor rules, permissions, and hooks that can live with the repository. Kiro setup registers tokensave in `~/.kiro/settings/mcp.json`, writes steering to `~/.kiro/steering/tokensave.md`, and writes a tokensave-managed agent that loads @@ -225,6 +225,21 @@ tokensave install --local --agent copilot Local installs write workspace files instead of user-level agent config. Supported local targets are Claude Code, Codex, Gemini, Kiro, OpenCode, GitHub Copilot / VS Code, Cursor, Zed, Roo Code, Kimi, Kilo, and Mistral Vibe. Examples include `.mcp.json`, `.claude/settings.json`, `.cursor/mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, `.kiro/settings/mcp.json`, `opencode.json`, `.roo/mcp.json`, `.kimi-code/mcp.json`, `kilo.json`, and `.vibe/config.toml`. +Cursor local install creates a stronger project-local setup: + +- `.cursor/mcp.json` registers the tokensave MCP server. +- `.cursor/rules/tokensave.mdc` tells Cursor Agent to prefer tokensave MCP tools for codebase exploration and to fall back to file reads/search only when needed. +- `.cursor/permissions.json` auto-allows read-only tokensave MCP tools using Cursor's `mcpAllowlist` format while leaving mutating edit/session tools subject to normal approval. +- `.cursor/hooks.json` installs Cursor-specific, fail-open project hooks (each acts only when a `.tokensave/` index exists): + - `sessionStart` injects context steering the Agent toward tokensave MCP tools and reports index freshness (suggests `tokensave init` when uninitialized). + - `subagentStart` denies research/explore subagents with Cursor's documented hook response shape. + - `beforeSubmitPrompt` resets the local token counter. + - `afterFileEdit` (matcher `Write`) runs a **targeted single-file** sync of only the edited path(s) — not a full-tree scan — so it stays cheap on large codebases even when the Agent edits many files per turn. + - `afterShellExecution` makes branch handling automatic: Agent-run `git checkout`/`switch`/`worktree add` bootstraps/maintains tokensave branch tracking (`branch add`), while other state-changing git commands (pull/merge/rebase/reset/cherry-pick/stash apply|pop) trigger a coalesced incremental sync. + - `workspaceOpen` ensures the current branch's DB exists (branch add if missing) and runs a catch-up incremental sync. + + Blind spot: Cursor hooks only observe the Cursor Agent's own actions and IDE lifecycle. Manual or external-terminal `git checkout` and in-place branch switches are not visible to these hooks (`workspaceOpen` does not fire for an in-place checkout). Use the git post-commit hook and the on-demand MCP staleness check to keep the index fresh for those cases. `beforeReadFile`/`preToolUse` blocking hooks are intentionally omitted for now to avoid noise; they may become opt-in later. + The generated MCP entries use the resolved absolute path to the current `tokensave` executable. A local install does not update `~/.tokensave/config.toml`, installed-agent tracking, the last installed version, or the global git post-commit hook prompt. Antigravity and Cline do not currently have documented project-local config paths, so `tokensave install --local --agent antigravity` and `tokensave install --local --agent cline` are rejected with unsupported-agent errors. #### Config backups diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index da8b748ac..931de78d0 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -7,11 +7,12 @@ use std::path::Path; use serde_json::json; -use crate::errors::Result; +use crate::errors::{Result, TokenSaveError}; use super::{ backup_and_write_json, backup_config_file, load_json_file, load_json_file_strict, - safe_write_json_file, AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, + load_jsonc_file_strict, read_only_tool_names, safe_write_json_file, AgentIntegration, + DoctorCounters, HealthcheckContext, InstallContext, }; /// Cursor agent. @@ -41,7 +42,11 @@ impl AgentIntegration for CursorIntegration { } fn install_local(&self, ctx: &InstallContext, project_path: &Path) -> Result<()> { - install_mcp_server(&project_path.join(".cursor/mcp.json"), &ctx.tokensave_bin) + let cursor_dir = project_path.join(".cursor"); + install_mcp_server(&cursor_dir.join("mcp.json"), &ctx.tokensave_bin)?; + install_project_rule(&cursor_dir.join("rules/tokensave.mdc"))?; + install_permissions(&cursor_dir.join("permissions.json"))?; + install_hooks(&cursor_dir.join("hooks.json"), &ctx.tokensave_bin) } fn uninstall(&self, ctx: &InstallContext) -> Result<()> { @@ -99,6 +104,7 @@ fn install_mcp_server(mcp_path: &Path, tokensave_bin: &str) -> Result<()> { } }; settings["mcpServers"]["tokensave"] = json!({ + "type": "stdio", "command": tokensave_bin, "args": ["serve"] }); @@ -111,6 +117,189 @@ fn install_mcp_server(mcp_path: &Path, tokensave_bin: &str) -> Result<()> { Ok(()) } +fn install_project_rule(rule_path: &Path) -> Result<()> { + let contents = r#"--- +description: Prefer tokensave MCP tools for codebase exploration +alwaysApply: true +--- + +# Prefer tokensave MCP tools + +- For codebase exploration, symbol lookup, call graphs, callers/callees, impact analysis, affected files, and architectural navigation, use the tokensave MCP tools first. +- Prefer tools such as `tokensave_context`, `tokensave_search`, `tokensave_callers`, `tokensave_callees`, `tokensave_impact`, `tokensave_files`, `tokensave_affected`, and related read-only tokensave tools before broad file reads or search. +- Only fall back to regular file reads, search, or shell commands when tokensave cannot answer the question or after tokensave has identified the exact files or symbols to inspect. +"#; + write_generated_text(rule_path, contents)?; + eprintln!( + "\x1b[32m✔\x1b[0m Wrote Cursor project rule to {}", + rule_path.display() + ); + Ok(()) +} + +fn install_permissions(permissions_path: &Path) -> Result<()> { + let backup = backup_config_file(permissions_path)?; + let mut permissions = match load_jsonc_file_strict(permissions_path) { + Ok(v) => v, + Err(e) => { + if let Some(ref b) = backup { + eprintln!(" Backup preserved at: {}", b.display()); + } + return Err(e); + } + }; + + let existing = permissions["mcpAllowlist"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + let mut allow = existing; + for tool in read_only_tool_names() { + let entry = format!("tokensave:{tool}"); + if !allow.iter().any(|existing| existing == &entry) { + allow.push(entry); + } + } + permissions["mcpAllowlist"] = json!(allow); + + safe_write_json_file(permissions_path, &permissions, backup.as_deref())?; + eprintln!( + "\x1b[32m✔\x1b[0m Added Cursor MCP permissions to {}", + permissions_path.display() + ); + Ok(()) +} + +fn install_hooks(hooks_path: &Path, tokensave_bin: &str) -> Result<()> { + let backup = backup_config_file(hooks_path)?; + let mut hooks = match load_jsonc_file_strict(hooks_path) { + Ok(v) => v, + Err(e) => { + if let Some(ref b) = backup { + eprintln!(" Backup preserved at: {}", b.display()); + } + return Err(e); + } + }; + + hooks["version"] = json!(1); + // Reduce wasteful exploration. + install_cursor_hook_entry( + &mut hooks, + "sessionStart", + tokensave_bin, + "hook-cursor-session-start", + 5, + None, + ); + install_cursor_hook_entry( + &mut hooks, + "subagentStart", + tokensave_bin, + "hook-cursor-subagent-start", + 5, + None, + ); + install_cursor_hook_entry( + &mut hooks, + "beforeSubmitPrompt", + tokensave_bin, + "hook-cursor-before-submit-prompt", + 5, + None, + ); + // Keep the index fresh. afterFileEdit uses a targeted single-file sync and + // is scoped to agent `Write` edits via a matcher. + install_cursor_hook_entry( + &mut hooks, + "afterFileEdit", + tokensave_bin, + "hook-cursor-after-file-edit", + 30, + Some("Write"), + ); + install_cursor_hook_entry( + &mut hooks, + "afterShellExecution", + tokensave_bin, + "hook-cursor-after-shell", + 60, + None, + ); + install_cursor_hook_entry( + &mut hooks, + "workspaceOpen", + tokensave_bin, + "hook-cursor-workspace-open", + 60, + None, + ); + + safe_write_json_file(hooks_path, &hooks, backup.as_deref())?; + eprintln!( + "\x1b[32m✔\x1b[0m Added Cursor project hooks to {}", + hooks_path.display() + ); + Ok(()) +} + +fn install_cursor_hook_entry( + hooks: &mut serde_json::Value, + event: &str, + tokensave_bin: &str, + subcommand: &str, + timeout: u64, + matcher: Option<&str>, +) { + let existing = hooks["hooks"][event] + .as_array() + .cloned() + .unwrap_or_default(); + + // Rebuild the tokensave-owned entry every install so refinements (matcher, + // timeout) reach pre-existing configs, while preserving any foreign hooks. + // Idempotent: there is always exactly one tokensave entry per event. + let mut event_hooks: Vec = existing + .into_iter() + .filter(|hook| { + !hook + .get("command") + .and_then(|v| v.as_str()) + .is_some_and(|command| command.contains(subcommand)) + }) + .collect(); + + let mut entry = json!({ + "command": format!("{} {subcommand}", shell_quote(tokensave_bin)), + "timeout": timeout + }); + if let Some(matcher) = matcher { + entry["matcher"] = json!(matcher); + } + event_hooks.push(entry); + + hooks["hooks"][event] = serde_json::Value::Array(event_hooks); +} + +fn write_generated_text(path: &Path, contents: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| TokenSaveError::Config { + message: format!("failed to create {}: {e}", parent.display()), + })?; + } + std::fs::write(path, contents).map_err(|e| TokenSaveError::Config { + message: format!("failed to write {}: {e}", path.display()), + }) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + /// Remove MCP server entry from ~/.cursor/mcp.json. fn uninstall_mcp_server(mcp_path: &Path) { if !mcp_path.exists() { diff --git a/src/branch.rs b/src/branch.rs index 1c4932996..2d53c5c2b 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -182,6 +182,84 @@ pub fn find_nearest_tracked_ancestor( best.map(|(name, _)| name) } +/// Outcome of [`add_branch_tracking`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BranchAddOutcome { + /// The project has no `.tokensave/` index; nothing was done. + NotIndexed, + /// The branch was already tracked; no copy/sync was performed. + AlreadyTracked, + /// A new branch DB was created from the nearest ancestor and synced. + Added, +} + +/// Silently bootstraps/maintains tokensave branch tracking for `branch_name`. +/// +/// This is the library-level core shared with the `tokensave branch add` CLI +/// command, callable from hooks without shelling out to a second process. It: +/// loads or bootstraps [`BranchMeta`] (via [`detect_default_branch`]), no-ops +/// when the branch is already tracked, otherwise copies the nearest tracked +/// ancestor's DB and runs an incremental sync against the new branch DB. +/// +/// No-ops (returns [`BranchAddOutcome::NotIndexed`]) when the project has no +/// `.tokensave/` index, so it never bootstraps indexing in an unindexed repo. +/// Idempotent: a re-add of a tracked branch returns +/// [`BranchAddOutcome::AlreadyTracked`] without re-copying. +pub async fn add_branch_tracking( + project_root: &Path, + branch_name: &str, +) -> crate::errors::Result { + use crate::branch_meta; + use crate::config::get_tokensave_dir; + + if !crate::tokensave::TokenSave::is_initialized(project_root) { + return Ok(BranchAddOutcome::NotIndexed); + } + let tokensave_dir = get_tokensave_dir(project_root); + + let mut meta = branch_meta::load_branch_meta(&tokensave_dir).unwrap_or_else(|| { + let default = detect_default_branch(project_root).unwrap_or_else(|| "main".to_string()); + branch_meta::BranchMeta::new(&default) + }); + + if meta.is_tracked(branch_name) { + return Ok(BranchAddOutcome::AlreadyTracked); + } + + let parent = find_nearest_tracked_ancestor(project_root, branch_name, &meta) + .unwrap_or_else(|| meta.default_branch.clone()); + let parent_db = resolve_branch_db_path(&tokensave_dir, &parent, &meta).ok_or_else(|| { + crate::errors::TokenSaveError::Config { + message: format!("parent branch '{parent}' has no DB"), + } + })?; + if !parent_db.exists() { + return Err(crate::errors::TokenSaveError::Config { + message: format!("parent DB not found at '{}'", parent_db.display()), + }); + } + + let sanitized = sanitize_branch_name(branch_name); + let branches_dir = branch_meta::ensure_branches_dir(&tokensave_dir)?; + let new_db_path = branches_dir.join(format!("{sanitized}.db")); + std::fs::copy(&parent_db, &new_db_path)?; + + // Save metadata BEFORE open() so it resolves the new branch to its DB. + let db_file = format!("branches/{sanitized}.db"); + meta.add_branch(branch_name, &db_file, &parent); + branch_meta::save_branch_meta(&tokensave_dir, &meta)?; + + let cg = crate::tokensave::TokenSave::open(project_root).await?; + let _ = cg.sync().await?; + + if let Some(mut meta) = branch_meta::load_branch_meta(&tokensave_dir) { + meta.touch_synced(branch_name); + let _ = branch_meta::save_branch_meta(&tokensave_dir, &meta); + } + + Ok(BranchAddOutcome::Added) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/src/cli.rs b/src/cli.rs index eccfc881c..b65fe620e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -119,6 +119,24 @@ pub enum Commands { /// Kiro PostToolUse hook handler for incremental sync #[command(name = "hook-kiro-post-tool-use", hide = true)] HookKiroPostToolUse, + /// Cursor subagentStart hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-subagent-start", hide = true)] + HookCursorSubagentStart, + /// Cursor beforeSubmitPrompt hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-before-submit-prompt", hide = true)] + HookCursorBeforeSubmitPrompt, + /// Cursor afterFileEdit hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-after-file-edit", hide = true)] + HookCursorAfterFileEdit, + /// Cursor sessionStart hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-session-start", hide = true)] + HookCursorSessionStart, + /// Cursor afterShellExecution hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-after-shell", hide = true)] + HookCursorAfterShell, + /// Cursor workspaceOpen hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-workspace-open", hide = true)] + HookCursorWorkspaceOpen, /// Start MCP server over stdio Serve { /// Project path diff --git a/src/hooks.rs b/src/hooks.rs index 168d6001c..1cb2f09d7 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -102,6 +102,533 @@ pub fn hook_kiro_pre_tool_use() -> i32 { } } +/// Cursor `subagentStart` hook handler. +/// +/// Cursor sends hook event JSON on stdin and expects Cursor-shaped JSON on +/// stdout. This intentionally does not reuse the Claude hook output schema. +pub fn hook_cursor_subagent_start() -> i32 { + let event = read_stdin_to_string(); + if let Some(decision) = evaluate_cursor_subagent_start(&event) { + println!("{decision}"); + } + 0 +} + +/// Cursor `beforeSubmitPrompt` hook handler. +/// +/// Resets the project-local counter for a new prompt turn. The output uses +/// Cursor's documented `beforeSubmitPrompt` shape and never blocks submission. +pub async fn hook_cursor_before_submit_prompt() -> i32 { + let event = read_stdin_to_string(); + reset_counter_for_cursor_event(&event).await; + println!("{}", serde_json::json!({ "continue": true })); + 0 +} + +/// Cursor `afterFileEdit` hook handler. +/// +/// Keeps the graph fresh after Cursor Agent writes files. This uses a +/// **targeted** single-file sync (`sync_if_stale_silent`) scoped to the edited +/// path(s) rather than a full-tree `sync()`. The agent can edit many files per +/// turn, and a full-tree scan per edit scales with repo size, not edit size — +/// prohibitively expensive on large codebases. The targeted path skips the +/// scan, no-ops when not stale, and waits/gives up on the sync lock, so no +/// time-based debounce is needed. Fail-open and silent. +pub async fn hook_cursor_after_file_edit() -> i32 { + let event = read_stdin_to_string(); + targeted_sync_for_cursor_after_file_edit(&event).await; + 0 +} + +/// Cursor `sessionStart` hook handler (fire-and-forget). +/// +/// Emits Cursor's `sessionStart` output shape (`additional_context` + `env`) +/// steering the agent toward tokensave MCP tools and reporting index freshness +/// for the resolved workspace. Never blocks session creation. +pub async fn hook_cursor_session_start() -> i32 { + let event = read_stdin_to_string(); + let root = cursor_project_root_from_event(&event); + let (initialized, staleness) = match &root { + Some(r) if crate::tokensave::TokenSave::is_initialized(r) => { + (true, cursor_staleness_for_root(r).await) + } + _ => (false, None), + }; + let context = build_cursor_session_context(initialized, staleness.as_deref()); + println!("{}", cursor_session_start_json(root.as_deref(), &context)); + 0 +} + +/// Cursor `afterShellExecution` hook handler. +/// +/// When the executed command is a git state-changing command (checkout, +/// switch, pull, merge, rebase, reset, cherry-pick, stash apply/pop), a +/// broader change set is expected, so a full incremental `sync()` is +/// acceptable. Back-to-back git commands are coalesced via a short marker-based +/// guard (and the sync lock no-ops concurrent runs). Fail-open and silent. +pub async fn hook_cursor_after_shell() -> i32 { + let event = read_stdin_to_string(); + sync_after_cursor_shell_event(&event).await; + 0 +} + +/// Cursor `workspaceOpen` hook handler. +/// +/// Runs a one-shot catch-up incremental `sync()` when the workspace has a +/// tokensave index, picking up changes made while no agent was attached. We +/// don't load plugins, so the output is an empty object. Fail-open. +pub async fn hook_cursor_workspace_open() -> i32 { + let event = read_stdin_to_string(); + workspace_open_for_cursor_event(&event).await; + println!("{}", serde_json::json!({})); + 0 +} + +/// Pure decision logic for Cursor `subagentStart` hook events. +/// +/// Returns a Cursor hook response only when a research-oriented subagent should +/// be denied in favor of tokensave MCP tools. +pub fn evaluate_cursor_subagent_start(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let subagent_type = parsed + .get("subagent_type") + .and_then(Value::as_str) + .unwrap_or_default(); + let task = parsed + .get("task") + .and_then(Value::as_str) + .unwrap_or_default(); + + let is_explore = subagent_type.eq_ignore_ascii_case("explore"); + if is_explore || is_code_research_prompt(task) { + return Some( + serde_json::json!({ + "permission": "deny", + "user_message": TOKENSAVE_RESEARCH_BLOCK_REASON + }) + .to_string(), + ); + } + + None +} + +pub fn cursor_project_root_from_event(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + cursor_event_candidates(&parsed) + .into_iter() + .find_map(|candidate| crate::config::discover_project_root(&candidate)) +} + +fn cursor_event_candidates(event: &Value) -> Vec { + let mut candidates = Vec::new(); + if let Some(roots) = event.get("workspace_roots").and_then(Value::as_array) { + for root in roots { + if let Some(path) = root.as_str().filter(|s| !s.is_empty()) { + candidates.push(PathBuf::from(path)); + } + } + } + if let Some(cwd) = event + .get("cwd") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + candidates.push(PathBuf::from(cwd)); + } + if let Some(file_path) = event + .get("file_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + let path = Path::new(file_path); + candidates.push(path.parent().unwrap_or(path).to_path_buf()); + } + candidates +} + +/// Returns `true` when `command` is a git invocation that changes the working +/// tree / HEAD enough that a broad re-sync is warranted (checkout, switch, +/// pull, merge, rebase, reset, cherry-pick, `stash pop`/`stash apply`). +/// +/// Read-only git commands (`status`, `log`, `diff`), `commit`/`add`, and +/// non-git commands return `false`. Only commands whose first token is `git` +/// match, so `echo git checkout` is ignored. +pub fn is_git_state_changing_command(command: &str) -> bool { + let tokens: Vec = command + .split_whitespace() + .map(|t| t.to_ascii_lowercase()) + .collect(); + if tokens.first().map(String::as_str) != Some("git") { + return false; + } + let Some(sub) = tokens.iter().skip(1).find(|t| !t.starts_with('-')) else { + return false; + }; + match sub.as_str() { + "checkout" | "switch" | "pull" | "merge" | "rebase" | "reset" | "cherry-pick" => true, + "stash" => { + let after = tokens + .iter() + .skip_while(|t| t.as_str() != "stash") + .skip(1) + .find(|t| !t.starts_with('-')); + matches!(after.map(String::as_str), Some("pop") | Some("apply")) + } + _ => false, + } +} + +/// The action a Cursor `afterShellExecution` hook should take for a command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CursorShellSyncPlan { + /// Bootstrap/maintain branch tracking for the given branch (supersedes a + /// plain sync; the branch-add path copies the parent DB and syncs). + BranchAdd(String), + /// Run a full incremental sync (same-branch change set). + IncrementalSync, + /// Do nothing. + Noop, +} + +/// Classifies a shell command into the sync action a Cursor +/// `afterShellExecution` hook should take. Branch switches take precedence +/// over plain incremental syncs. +pub fn cursor_shell_sync_plan(command: &str) -> CursorShellSyncPlan { + if let Some(branch) = cursor_branch_switch_target(command) { + return CursorShellSyncPlan::BranchAdd(branch); + } + if is_git_state_changing_command(command) { + return CursorShellSyncPlan::IncrementalSync; + } + CursorShellSyncPlan::Noop +} + +/// Returns the target branch for a branch-changing git command: +/// `git checkout `, `git switch `, `git checkout -b `, +/// `git switch -c `, and `git worktree add [] ` / +/// `git worktree add -b `. +/// +/// Path checkouts (`git checkout -- ` or obvious file pathspecs) and +/// non-switch commands return `None`. Only commands whose first token is `git` +/// are considered. +pub fn cursor_branch_switch_target(command: &str) -> Option { + let raw: Vec<&str> = command.split_whitespace().collect(); + let lower: Vec = raw.iter().map(|t| t.to_ascii_lowercase()).collect(); + if lower.first().map(String::as_str) != Some("git") { + return None; + } + let sub = lower.iter().skip(1).find(|t| !t.starts_with('-'))?; + let sub_pos = lower.iter().position(|t| t == sub)?; + + match sub.as_str() { + "checkout" | "switch" => { + // Path checkout (`git checkout -- file`) is not a branch switch. + if raw.contains(&"--") { + return None; + } + let after = &raw[sub_pos + 1..]; + let mut iter = after.iter(); + while let Some(tok) = iter.next() { + if matches!(*tok, "-b" | "-B" | "-c" | "-C") { + return iter.find(|t| !t.starts_with('-')).map(|b| (*b).to_string()); + } + if tok.starts_with('-') { + continue; + } + if is_obvious_checkout_pathspec(tok) { + return None; + } + return Some((*tok).to_string()); + } + None + } + "worktree" => { + let add_pos = lower.iter().position(|t| t == "add")?; + let after = &raw[add_pos + 1..]; + let mut iter = after.iter(); + while let Some(tok) = iter.next() { + if matches!(*tok, "-b" | "-B") { + return iter.find(|t| !t.starts_with('-')).map(|b| (*b).to_string()); + } + if tok.starts_with('-') { + continue; + } + break; + } + // No `-b`: positionals are ` []`; the branch is the + // second positional, if present. + let positionals: Vec<&str> = after + .iter() + .filter(|t| !t.starts_with('-')) + .copied() + .collect(); + positionals.get(1).map(|b| (*b).to_string()) + } + _ => None, + } +} + +fn is_obvious_checkout_pathspec(token: &str) -> bool { + token == "." + || token == ":/" + || token.starts_with("./") + || token.starts_with("../") + || token.starts_with(":/") + || token + .rsplit_once('.') + .is_some_and(|(_, ext)| !ext.is_empty()) +} + +/// Extracts the repo-relative paths edited in a Cursor `afterFileEdit` event. +/// +/// Cursor sends an absolute `file_path` (plus an `edits` array). We strip the +/// resolved `project_root` prefix and normalize to forward slashes so the set +/// can be passed straight to [`TokenSave::sync_if_stale_silent`], which does a +/// targeted single-file sync instead of a full-tree scan. Paths outside the +/// project root are skipped. +pub fn cursor_after_file_edit_rel_paths(event_json: &str, project_root: &Path) -> Vec { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return Vec::new(); + }; + + let mut abs_paths: Vec = Vec::new(); + if let Some(p) = parsed + .get("file_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + abs_paths.push(p.to_string()); + } + // Defensive: some edit payloads may carry per-edit file paths. + if let Some(edits) = parsed.get("edits").and_then(Value::as_array) { + for edit in edits { + if let Some(p) = edit + .get("file_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + abs_paths.push(p.to_string()); + } + } + } + + let mut rels: Vec = Vec::new(); + for abs in abs_paths { + if let Some(rel) = rel_under_root(project_root, Path::new(&abs)) { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } + rels +} + +fn rel_under_root(root: &Path, abs: &Path) -> Option { + let stripped = abs.strip_prefix(root).ok()?; + if stripped.as_os_str().is_empty() { + return None; + } + Some(stripped.to_string_lossy().replace('\\', "/")) +} + +/// Returns `true` when a sync should run given the last marker time and a +/// debounce window. Used to coalesce back-to-back `afterShellExecution` syncs. +pub fn cursor_should_run_sync(now_secs: i64, last_secs: Option, debounce_secs: i64) -> bool { + match last_secs { + Some(last) => now_secs - last >= debounce_secs, + None => true, + } +} + +/// Builds the `sessionStart` `additional_context` text: steer the agent toward +/// tokensave MCP tools and report index freshness for the workspace. +pub fn build_cursor_session_context(initialized: bool, staleness_hint: Option<&str>) -> String { + let mut s = String::new(); + s.push_str( + "tokensave is available via MCP. Prefer tokensave MCP tools \ + (tokensave_context, tokensave_search, tokensave_callers, tokensave_callees, \ + tokensave_impact, tokensave_files, tokensave_affected) over broad file reads \ + or shell search for codebase exploration, symbol lookup, call graphs, and \ + impact analysis. Fall back to file reads only when tokensave cannot answer.\n", + ); + if initialized { + match staleness_hint { + Some(hint) => s.push_str(&format!("Index status: {hint}.\n")), + None => s.push_str("Index status: initialized.\n"), + } + } else { + s.push_str( + "Index status: no .tokensave/ index found in this workspace — \ + run `tokensave init` to enable tokensave tools.\n", + ); + } + s +} + +/// Formats a short relative-age staleness hint from a sync age in seconds. +pub fn cursor_staleness_hint(age_secs: i64) -> String { + let age = age_secs.max(0); + if age < 60 { + "last indexed just now".to_string() + } else if age < 3_600 { + format!("last indexed {}m ago", age / 60) + } else if age < 86_400 { + format!("last indexed {}h ago", age / 3_600) + } else { + format!("last indexed {}d ago", age / 86_400) + } +} + +/// Builds the Cursor `sessionStart` output JSON (`additional_context` + `env`). +/// When `project_root` is known, exposes it as `TOKENSAVE_PROJECT_ROOT` so +/// subsequent session hooks can reuse it. +pub fn cursor_session_start_json(project_root: Option<&Path>, additional_context: &str) -> String { + let mut env = serde_json::Map::new(); + if let Some(root) = project_root { + env.insert( + "TOKENSAVE_PROJECT_ROOT".to_string(), + Value::String(root.to_string_lossy().to_string()), + ); + } + serde_json::json!({ + "additional_context": additional_context, + "env": Value::Object(env), + }) + .to_string() +} + +async fn cursor_staleness_for_root(root: &Path) -> Option { + let cg = crate::tokensave::TokenSave::open(root).await.ok()?; + let last = cg.last_sync_timestamp().await; + if last <= 0 { + return None; + } + Some(cursor_staleness_hint(now_unix_secs() - last)) +} + +/// Targeted, fail-open single-file sync for Cursor `afterFileEdit`. +/// +/// Resolves the edited repo-relative paths and calls `sync_if_stale_silent`, +/// which avoids the full-tree scan that `sync()` performs. No-ops when the +/// workspace is uninitialized or no in-project paths were edited. +async fn targeted_sync_for_cursor_after_file_edit(event_json: &str) { + let Some(root) = cursor_project_root_from_event(event_json) else { + return; + }; + if !crate::tokensave::TokenSave::is_initialized(&root) { + return; + } + let rels = cursor_after_file_edit_rel_paths(event_json, &root); + if rels.is_empty() { + return; + } + if let Ok(cg) = crate::tokensave::TokenSave::open(&root).await { + let _ = cg.sync_if_stale_silent(&rels).await; + } +} + +/// Branch-aware, fail-open handler for git state-changing shell commands. +/// +/// Branch switches (`checkout`/`switch`/`worktree add`) bootstrap/maintain +/// tokensave branch tracking via [`crate::branch::add_branch_tracking`] — +/// which is idempotent and supersedes a plain sync. Other state-changing +/// commands (pull/merge/rebase/reset/cherry-pick/stash apply|pop) run a full +/// incremental `sync()`, coalesced by a short marker-based guard so back-to-back +/// git commands don't stack. Only acts when `.tokensave/` already exists. +async fn sync_after_cursor_shell_event(event_json: &str) { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return; + }; + let command = parsed + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + let plan = cursor_shell_sync_plan(command); + if matches!(plan, CursorShellSyncPlan::Noop) { + return; + } + let Some(root) = cursor_project_root_from_event(event_json) else { + return; + }; + // Never bootstrap indexing in an unindexed repo. + if !crate::tokensave::TokenSave::is_initialized(&root) { + return; + } + + match plan { + CursorShellSyncPlan::BranchAdd(branch) => { + // Idempotent + fail-open: already-tracked branches no-op. + let _ = crate::branch::add_branch_tracking(&root, &branch).await; + } + CursorShellSyncPlan::IncrementalSync => { + let marker = cursor_shell_sync_marker_path(&root); + let now = now_unix_secs(); + if !cursor_should_run_sync(now, read_marker_secs(&marker), 3) { + return; + } + write_marker_secs(&marker, now); + + if let Ok(cg) = crate::tokensave::TokenSave::open(&root).await { + match cg.sync().await { + Ok(_) | Err(crate::errors::TokenSaveError::SyncLock { .. }) => {} + Err(e) => eprintln!("tokensave sync failed: {e}"), + } + } + } + CursorShellSyncPlan::Noop => {} + } +} + +/// Branch-aware workspace catch-up for Cursor `workspaceOpen`. +/// +/// When the workspace has a tokensave index, ensures the current branch's DB +/// exists (branch-add if missing — which also syncs) and otherwise runs a +/// catch-up incremental `sync()`. Idempotent and fail-open. +async fn workspace_open_for_cursor_event(event_json: &str) { + let Some(root) = cursor_project_root_from_event(event_json) else { + return; + }; + if !crate::tokensave::TokenSave::is_initialized(&root) { + return; + } + + // Ensure the current branch is tracked. When a branch is freshly added, + // `add_branch_tracking` already runs a sync, so we can skip the catch-up. + if let Some(branch) = crate::branch::current_branch(&root) { + if let Ok(crate::branch::BranchAddOutcome::Added) = + crate::branch::add_branch_tracking(&root, &branch).await + { + return; + } + } + + let _ = sync_for_cursor_event(event_json).await; +} + +fn cursor_shell_sync_marker_path(root: &Path) -> PathBuf { + crate::config::get_tokensave_dir(root).join(".cursor_shell_sync_at") +} + +fn read_marker_secs(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok()? + .trim() + .parse::() + .ok() +} + +fn write_marker_secs(path: &Path, secs: i64) { + let _ = std::fs::write(path, secs.to_string()); +} + +fn now_unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + /// Pure decision logic for Kiro `preToolUse` hook events. /// /// Returns a block reason only for Kiro delegation/subagent tool calls whose @@ -224,6 +751,15 @@ async fn reset_counter_for_kiro_event(event_json: &str) { } } +async fn reset_counter_for_cursor_event(event_json: &str) { + let Some(project_root) = cursor_project_root_from_event(event_json) else { + return; + }; + if let Ok(cg) = crate::tokensave::TokenSave::open(&project_root).await { + let _ = cg.reset_local_counter().await; + } +} + async fn sync_for_kiro_event(event_json: &str) -> crate::errors::Result<()> { let Some(project_root) = kiro_project_root(event_json) else { return Ok(()); @@ -235,6 +771,20 @@ async fn sync_for_kiro_event(event_json: &str) -> crate::errors::Result<()> { } } +async fn sync_for_cursor_event(event_json: &str) -> crate::errors::Result<()> { + let Some(project_root) = cursor_project_root_from_event(event_json) else { + return Ok(()); + }; + if !crate::tokensave::TokenSave::is_initialized(&project_root) { + return Ok(()); + } + let cg = crate::tokensave::TokenSave::open(&project_root).await?; + match cg.sync().await { + Ok(_) | Err(crate::errors::TokenSaveError::SyncLock { .. }) => Ok(()), + Err(e) => Err(e), + } +} + fn kiro_project_root(event_json: &str) -> Option { let cwd = kiro_event_cwd(event_json).or_else(|| std::env::current_dir().ok())?; crate::config::discover_project_root(&cwd) diff --git a/src/main.rs b/src/main.rs index aee93fd20..a8802e957 100644 --- a/src/main.rs +++ b/src/main.rs @@ -773,6 +773,42 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { process::exit(code); } } + Commands::HookCursorSubagentStart => { + let code = tokensave::hooks::hook_cursor_subagent_start(); + if code != 0 { + process::exit(code); + } + } + Commands::HookCursorBeforeSubmitPrompt => { + let code = tokensave::hooks::hook_cursor_before_submit_prompt().await; + if code != 0 { + process::exit(code); + } + } + Commands::HookCursorAfterFileEdit => { + let code = tokensave::hooks::hook_cursor_after_file_edit().await; + if code != 0 { + process::exit(code); + } + } + Commands::HookCursorSessionStart => { + let code = tokensave::hooks::hook_cursor_session_start().await; + if code != 0 { + process::exit(code); + } + } + Commands::HookCursorAfterShell => { + let code = tokensave::hooks::hook_cursor_after_shell().await; + if code != 0 { + process::exit(code); + } + } + Commands::HookCursorWorkspaceOpen => { + let code = tokensave::hooks::hook_cursor_workspace_open().await; + if code != 0 { + process::exit(code); + } + } Commands::Serve { path, timings } => { if std::env::var("DISABLE_TOKENSAVE").as_deref() == Ok("true") { // Allow users to opt out per-project by setting @@ -1123,6 +1159,12 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { | Commands::Reinstall | Commands::Uninstall { .. } | Commands::Doctor { .. } + | Commands::HookCursorSubagentStart + | Commands::HookCursorBeforeSubmitPrompt + | Commands::HookCursorAfterFileEdit + | Commands::HookCursorSessionStart + | Commands::HookCursorAfterShell + | Commands::HookCursorWorkspaceOpen // `Serve` is the hot path used by MCP clients (Claude Code, // Codex, etc.). Clients impose a 30 s `initialize` timeout, so // every pre-serve startup task — `try_flush` network round-trip, diff --git a/tests/agent_test.rs b/tests/agent_test.rs index b5d63bc56..9cf9c83af 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -181,6 +181,128 @@ fn test_local_install_cursor_writes_project_config_only() { config["mcpServers"]["tokensave"]["args"], serde_json::json!(["serve"]) ); + assert_eq!( + config["mcpServers"]["tokensave"]["type"], + serde_json::json!("stdio") + ); + + let rule_path = project.path().join(".cursor/rules/tokensave.mdc"); + assert!(rule_path.exists(), "Cursor local rule should exist"); + let rule = std::fs::read_to_string(&rule_path).unwrap(); + assert!(rule.contains("alwaysApply: true")); + assert!(rule.contains("tokensave MCP tools")); + assert!(rule.contains("fall back")); + + let permissions_path = project.path().join(".cursor/permissions.json"); + assert!( + permissions_path.exists(), + "Cursor local permissions should exist" + ); + let permissions = read_json(&permissions_path); + let allow = permissions["mcpAllowlist"] + .as_array() + .expect("mcpAllowlist should be an array"); + let allow_strs: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect(); + for tool in read_only_tool_names() { + let expected = format!("tokensave:{tool}"); + assert!( + allow_strs.contains(&expected.as_str()), + "Cursor permissions should allow read-only MCP tool {expected}" + ); + } + for mutating in [ + "tokensave_str_replace", + "tokensave_multi_str_replace", + "tokensave_insert_at", + "tokensave_ast_grep_rewrite", + ] { + let denied = format!("tokensave:{mutating}"); + assert!( + !allow_strs.contains(&denied.as_str()), + "Cursor permissions should not auto-allow mutating MCP tool {denied}" + ); + } + + let hooks_path = project.path().join(".cursor/hooks.json"); + assert!( + hooks_path.exists(), + "Cursor local hooks config should exist" + ); + let hooks = read_json(&hooks_path); + let subagent_hooks = hooks["hooks"]["subagentStart"] + .as_array() + .expect("subagentStart hooks should be an array"); + let tokensave_hook = subagent_hooks + .iter() + .find(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-subagent-start")) + }) + .expect("Cursor subagentStart hook should call tokensave hook-cursor-subagent-start"); + assert_eq!(tokensave_hook["timeout"], serde_json::json!(5)); + let before_submit_hooks = hooks["hooks"]["beforeSubmitPrompt"] + .as_array() + .expect("beforeSubmitPrompt hooks should be an array"); + assert!( + before_submit_hooks.iter().any(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-before-submit-prompt")) + }), + "Cursor beforeSubmitPrompt hook should reset tokensave's local counter" + ); + let after_edit_hooks = hooks["hooks"]["afterFileEdit"] + .as_array() + .expect("afterFileEdit hooks should be an array"); + let after_edit_hook = after_edit_hooks + .iter() + .find(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-after-file-edit")) + }) + .expect("Cursor afterFileEdit hook should keep tokensave's index fresh after writes"); + assert_eq!( + after_edit_hook["matcher"], "Write", + "afterFileEdit hook should target agent Write edits via a matcher" + ); + + let session_start_hooks = hooks["hooks"]["sessionStart"] + .as_array() + .expect("sessionStart hooks should be an array"); + assert!( + session_start_hooks.iter().any(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-session-start")) + }), + "Cursor sessionStart hook should steer the agent toward tokensave MCP tools" + ); + + let after_shell_hooks = hooks["hooks"]["afterShellExecution"] + .as_array() + .expect("afterShellExecution hooks should be an array"); + assert!( + after_shell_hooks.iter().any(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-after-shell")) + }), + "Cursor afterShellExecution hook should resync after git state changes" + ); + + let workspace_open_hooks = hooks["hooks"]["workspaceOpen"] + .as_array() + .expect("workspaceOpen hooks should be an array"); + assert!( + workspace_open_hooks.iter().any(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-workspace-open")) + }), + "Cursor workspaceOpen hook should run a catch-up sync" + ); assert!( !home.path().join(".cursor/mcp.json").exists(), @@ -192,6 +314,48 @@ fn test_local_install_cursor_writes_project_config_only() { ); } +#[test] +fn test_local_install_cursor_reconciles_existing_hooks_idempotently() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + + // Pre-seed a hooks.json with a tokensave afterFileEdit entry that lacks + // the `Write` matcher (mirrors a config from an earlier tokensave version). + let cursor_dir = project.path().join(".cursor"); + std::fs::create_dir_all(&cursor_dir).unwrap(); + std::fs::write( + cursor_dir.join("hooks.json"), + r#"{"version":1,"hooks":{"afterFileEdit":[{"command":"/old/tokensave hook-cursor-after-file-edit","timeout":30}]}}"#, + ) + .unwrap(); + + // Install twice to prove idempotent reconciliation. + assert_local_install_success("cursor", project.path(), home.path()); + assert_local_install_success("cursor", project.path(), home.path()); + + let hooks = read_json(&cursor_dir.join("hooks.json")); + let after = hooks["hooks"]["afterFileEdit"] + .as_array() + .expect("afterFileEdit should be an array"); + let tokensave_entries: Vec<_> = after + .iter() + .filter(|hook| { + hook["command"] + .as_str() + .is_some_and(|command| command.contains("hook-cursor-after-file-edit")) + }) + .collect(); + assert_eq!( + tokensave_entries.len(), + 1, + "reinstall must keep exactly one tokensave afterFileEdit entry, got {after:?}" + ); + assert_eq!( + tokensave_entries[0]["matcher"], "Write", + "reinstall must reconcile the matcher onto a pre-existing entry" + ); +} + #[test] fn test_local_install_supported_agents_write_project_paths() { let cases = [ @@ -216,6 +380,15 @@ fn test_local_install_supported_agents_write_project_paths() { ("kimi", vec![".kimi-code/mcp.json", "AGENTS.md"]), ("kilo", vec!["kilo.json"]), ("vibe", vec![".vibe/config.toml", ".vibe/prompts/cli.md"]), + ( + "cursor", + vec![ + ".cursor/mcp.json", + ".cursor/rules/tokensave.mdc", + ".cursor/permissions.json", + ".cursor/hooks.json", + ], + ), ]; for (agent, paths) in cases { @@ -237,7 +410,12 @@ fn test_local_install_supported_agents_write_project_paths() { "{agent} local file {} should mention tokensave", path.display() ); - if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + let is_instruction_file = matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("md" | "mdc") + ); + let is_cursor_permissions = agent == "cursor" && relative == ".cursor/permissions.json"; + if !is_instruction_file && !is_cursor_permissions { let expected = expected_tokensave_bin(); assert!( body.contains(&expected), diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index 9923a2e85..4722c3b2c 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -1,4 +1,9 @@ -use tokensave::hooks::{evaluate_hook_decision, evaluate_kiro_pre_tool_use}; +use tokensave::hooks::{ + build_cursor_session_context, cursor_branch_switch_target, cursor_project_root_from_event, + cursor_session_start_json, cursor_shell_sync_plan, cursor_should_run_sync, + cursor_staleness_hint, evaluate_cursor_subagent_start, evaluate_hook_decision, + evaluate_kiro_pre_tool_use, is_git_state_changing_command, CursorShellSyncPlan, +}; fn is_blocked(json: &str) -> bool { let v: serde_json::Value = serde_json::from_str(json).unwrap(); @@ -202,3 +207,293 @@ fn test_kiro_allows_non_delegation_tool() { fn test_kiro_allows_invalid_json() { assert!(evaluate_kiro_pre_tool_use("not json").is_none()); } + +#[test] +fn test_cursor_subagent_start_blocks_explore_research_task() { + let input = r#"{ + "hook_event_name": "subagentStart", + "subagent_type": "explore", + "task": "Explore the codebase architecture and call graph" + }"#; + + let output = evaluate_cursor_subagent_start(input).expect("should deny research subagent"); + let v: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(v["permission"].as_str(), Some("deny")); + assert!(v["user_message"] + .as_str() + .unwrap_or_default() + .contains("tokensave MCP tools")); + assert!( + v.get("hookSpecificOutput").is_none(), + "Cursor hook output must use Cursor's documented subagentStart fields" + ); +} + +#[test] +fn test_cursor_subagent_start_allows_execution_task() { + let input = r#"{ + "hook_event_name": "subagentStart", + "subagent_type": "generalPurpose", + "task": "Run the test suite and summarize failures" + }"#; + + assert!(evaluate_cursor_subagent_start(input).is_none()); +} + +#[test] +fn test_cursor_project_root_uses_workspace_roots() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".tokensave")).unwrap(); + std::fs::write(dir.path().join(".tokensave/tokensave.db"), "").unwrap(); + let input = format!( + r#"{{ + "hook_event_name": "beforeSubmitPrompt", + "workspace_roots": [{}] + }}"#, + serde_json::to_string(dir.path().to_str().unwrap()).unwrap() + ); + + assert_eq!( + cursor_project_root_from_event(&input), + Some(dir.path().to_path_buf()) + ); +} + +#[test] +fn test_cursor_project_root_uses_file_path_parent() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + std::fs::create_dir_all(dir.path().join(".tokensave")).unwrap(); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(dir.path().join(".tokensave/tokensave.db"), "").unwrap(); + let file = src.join("lib.rs"); + let input = format!( + r#"{{ + "hook_event_name": "afterFileEdit", + "file_path": {} + }}"#, + serde_json::to_string(file.to_str().unwrap()).unwrap() + ); + + assert_eq!( + cursor_project_root_from_event(&input), + Some(dir.path().to_path_buf()) + ); +} + +#[test] +fn test_is_git_state_changing_command_detects_branch_switches() { + for command in [ + "git checkout main", + "git switch -c feature/x", + "git pull --rebase", + "git merge origin/main", + "git rebase main", + "git reset --hard HEAD~1", + "git cherry-pick abc123", + "git stash pop", + "git stash apply stash@{0}", + " GIT checkout main ", + ] { + assert!( + is_git_state_changing_command(command), + "{command} should be treated as a git state-changing command" + ); + } +} + +#[test] +fn test_is_git_state_changing_command_ignores_read_only_and_non_git() { + for command in [ + "git status", + "git log --oneline", + "git diff", + "git commit -m wip", + "git add .", + "git stash list", + "ls -la", + "cargo test", + "echo git checkout", + ] { + assert!( + !is_git_state_changing_command(command), + "{command} should NOT trigger a sync" + ); + } +} + +#[test] +fn test_cursor_after_file_edit_rel_paths_targets_edited_files() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + let edited = root.join("src/lib.rs"); + let input = format!( + r#"{{ + "hook_event_name": "afterFileEdit", + "file_path": {}, + "edits": [{{ "old_string": "a", "new_string": "b" }}] + }}"#, + serde_json::to_string(edited.to_str().unwrap()).unwrap() + ); + + let rels = tokensave::hooks::cursor_after_file_edit_rel_paths(&input, &root); + assert_eq!(rels, vec!["src/lib.rs".to_string()]); +} + +#[test] +fn test_cursor_after_file_edit_rel_paths_skips_paths_outside_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let input = r#"{ + "hook_event_name": "afterFileEdit", + "file_path": "/etc/passwd" + }"#; + + let rels = tokensave::hooks::cursor_after_file_edit_rel_paths(input, &root); + assert!( + rels.is_empty(), + "paths outside the project root must be ignored, got {rels:?}" + ); +} + +#[test] +fn test_cursor_should_run_sync_respects_debounce_window() { + assert!(cursor_should_run_sync(1_000, None, 3)); + assert!(cursor_should_run_sync(1_000, Some(996), 3)); + assert!(!cursor_should_run_sync(1_000, Some(998), 3)); + assert!(!cursor_should_run_sync(1_000, Some(1_000), 3)); +} + +#[test] +fn test_build_cursor_session_context_uninitialized_suggests_init() { + let context = build_cursor_session_context(false, None); + assert!(context.contains("tokensave init")); + assert!(context.contains("tokensave MCP tools")); + assert!(context.contains("tokensave_context")); +} + +#[test] +fn test_build_cursor_session_context_initialized_includes_freshness() { + let context = build_cursor_session_context(true, Some("last indexed 2m ago")); + assert!(context.contains("tokensave_context")); + assert!(context.contains("last indexed 2m ago")); + assert!( + !context.contains("tokensave init"), + "initialized workspaces should not be told to run init: {context}" + ); +} + +#[test] +fn test_cursor_staleness_hint_formats_relative_age() { + assert!(cursor_staleness_hint(0).contains("just")); + assert!(cursor_staleness_hint(120).contains('m')); + assert!(cursor_staleness_hint(7_200).contains('h')); +} + +#[test] +fn test_cursor_session_start_json_sets_context_and_env_root() { + let dir = tempfile::tempdir().unwrap(); + let json = cursor_session_start_json(Some(dir.path()), "hello context"); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["additional_context"], "hello context"); + assert_eq!( + v["env"]["TOKENSAVE_PROJECT_ROOT"].as_str(), + Some(dir.path().to_string_lossy().as_ref()) + ); +} + +#[test] +fn test_cursor_session_start_json_without_root_omits_env_path() { + let json = cursor_session_start_json(None, "ctx"); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["additional_context"], "ctx"); + assert!(v["env"].get("TOKENSAVE_PROJECT_ROOT").is_none()); +} + +#[test] +fn test_cursor_branch_switch_target_extracts_branch() { + assert_eq!( + cursor_branch_switch_target("git checkout main"), + Some("main".to_string()) + ); + assert_eq!( + cursor_branch_switch_target("git switch develop"), + Some("develop".to_string()) + ); + assert_eq!( + cursor_branch_switch_target("git checkout -b feature/x"), + Some("feature/x".to_string()) + ); + assert_eq!( + cursor_branch_switch_target("git switch -c feature/y"), + Some("feature/y".to_string()) + ); + assert_eq!( + cursor_branch_switch_target("git worktree add ../wt feature/z"), + Some("feature/z".to_string()) + ); + assert_eq!( + cursor_branch_switch_target("git worktree add -b newbranch ../wt"), + Some("newbranch".to_string()) + ); +} + +#[test] +fn test_cursor_branch_switch_target_ignores_path_checkouts_and_non_switches() { + assert_eq!( + cursor_branch_switch_target("git checkout -- src/main.rs"), + None + ); + assert_eq!(cursor_branch_switch_target("git checkout README.md"), None); + assert_eq!(cursor_branch_switch_target("git pull --rebase"), None); + assert_eq!(cursor_branch_switch_target("git merge origin/main"), None); + assert_eq!(cursor_branch_switch_target("git status"), None); + assert_eq!(cursor_branch_switch_target("echo git checkout main"), None); +} + +#[test] +fn test_cursor_shell_sync_plan_routes_branch_switch_to_branch_add() { + assert_eq!( + cursor_shell_sync_plan("git checkout main"), + CursorShellSyncPlan::BranchAdd("main".to_string()) + ); + assert_eq!( + cursor_shell_sync_plan("git switch -c feature/x"), + CursorShellSyncPlan::BranchAdd("feature/x".to_string()) + ); + assert_eq!( + cursor_shell_sync_plan("git worktree add ../wt feature/z"), + CursorShellSyncPlan::BranchAdd("feature/z".to_string()) + ); +} + +#[test] +fn test_cursor_shell_sync_plan_routes_same_branch_changes_to_incremental_sync() { + for command in [ + "git pull --rebase", + "git merge origin/main", + "git rebase main", + "git reset --hard HEAD~1", + "git cherry-pick abc123", + "git stash pop", + ] { + assert_eq!( + cursor_shell_sync_plan(command), + CursorShellSyncPlan::IncrementalSync, + "{command} should route to an incremental sync" + ); + } +} + +#[test] +fn test_cursor_shell_sync_plan_noop_for_read_only_and_non_git() { + for command in ["git status", "git log", "ls -la", "cargo build"] { + assert_eq!( + cursor_shell_sync_plan(command), + CursorShellSyncPlan::Noop, + "{command} should be a no-op" + ); + } +}