From 98d56842572a8dbc6cc2758c4264f1e2d3a445ba Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 01:04:35 +0200 Subject: [PATCH 01/10] feat(install): add project-local Cursor and Codex setup --- .codex/config.toml | 27 +++-- .cursor/mcp.json | 6 +- .cursor/permissions.json | 15 ++- AGENTS.md | 12 +++ scripts/tokensave-dev-mcp.sh | 18 ++++ src/agents/codex.rs | 122 +++++++++++++++------- src/agents/copilot.rs | 11 +- src/agents/cursor.rs | 191 +++++++++++++++++++++++++++++++++-- src/agents/vibe.rs | 9 +- src/global.rs | 9 +- src/hooks.rs | 11 ++ src/main.rs | 77 +++++++++----- src/user_config.rs | 16 +++ tests/agent_test.rs | 159 +++++++++++++++++++++++++---- 14 files changed, 573 insertions(+), 110 deletions(-) create mode 100755 scripts/tokensave-dev-mcp.sh diff --git a/.codex/config.toml b/.codex/config.toml index f8c80a573a..b89991bd75 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,5 +1,9 @@ [mcp_servers.tokensave] -args = ["serve"] +args = [ + "serve", + "--path", + ".", +] command = "/home/zack/projects/tokensave/target/debug/tokensave" [mcp_servers.tokensave.tools.tokensave_affected] @@ -83,6 +87,12 @@ approval_mode = "auto" [mcp_servers.tokensave.tools.tokensave_dsm] approval_mode = "auto" +[mcp_servers.tokensave.tools.tokensave_fact_feedback] +approval_mode = "auto" + +[mcp_servers.tokensave.tools.tokensave_fact_store] +approval_mode = "auto" + [mcp_servers.tokensave.tools.tokensave_field_sites] approval_mode = "auto" @@ -128,6 +138,12 @@ approval_mode = "auto" [mcp_servers.tokensave.tools.tokensave_largest] approval_mode = "auto" +[mcp_servers.tokensave.tools.tokensave_memory_status] +approval_mode = "auto" + +[mcp_servers.tokensave.tools.tokensave_message_search] +approval_mode = "auto" + [mcp_servers.tokensave.tools.tokensave_module_api] approval_mode = "auto" @@ -155,12 +171,6 @@ approval_mode = "auto" [mcp_servers.tokensave.tools.tokensave_read] approval_mode = "auto" -[mcp_servers.tokensave.tools.tokensave_record_code_area] -approval_mode = "auto" - -[mcp_servers.tokensave.tools.tokensave_record_decision] -approval_mode = "auto" - [mcp_servers.tokensave.tools.tokensave_recursion] approval_mode = "auto" @@ -185,9 +195,6 @@ approval_mode = "auto" [mcp_servers.tokensave.tools.tokensave_session_end] approval_mode = "auto" -[mcp_servers.tokensave.tools.tokensave_session_recall] -approval_mode = "auto" - [mcp_servers.tokensave.tools.tokensave_session_start] approval_mode = "auto" diff --git a/.cursor/mcp.json b/.cursor/mcp.json index f116fa3412..5c790bc507 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,11 +1,7 @@ { "mcpServers": { "tokensave": { - "args": [ - "serve" - ], - "command": "/home/zack/projects/tokensave/target/debug/tokensave", - "type": "stdio" + "command": "/home/zack/projects/tokensave/scripts/tokensave-dev-mcp.sh" } } } diff --git a/.cursor/permissions.json b/.cursor/permissions.json index 74f3b1d3fb..8de5bc8f60 100644 --- a/.cursor/permissions.json +++ b/.cursor/permissions.json @@ -52,7 +52,6 @@ "tokensave:tokensave_impls", "tokensave:tokensave_diagnose", "tokensave:tokensave_derives", - "tokensave:tokensave_session_recall", "tokensave:tokensave_read", "tokensave:tokensave_outline", "tokensave:tokensave_implementations", @@ -64,6 +63,18 @@ "tokensave:tokensave_field_sites", "tokensave:tokensave_call_chain", "tokensave:tokensave_file_dependents", - "tokensave:tokensave_find_exact_symbol" + "tokensave:tokensave_find_exact_symbol", + "tokensave:tokensave_fact_store", + "tokensave:tokensave_fact_feedback", + "tokensave:tokensave_memory_status", + "tokensave:tokensave_str_replace", + "tokensave:tokensave_multi_str_replace", + "tokensave:tokensave_insert_at", + "tokensave:tokensave_session_start", + "tokensave:tokensave_session_end", + "tokensave:tokensave_run_affected_tests", + "tokensave:tokensave_replace_symbol", + "tokensave:tokensave_insert_at_symbol", + "tokensave:tokensave_message_search" ] } diff --git a/AGENTS.md b/AGENTS.md index 3afc646ef6..fa58d0a2da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,15 @@ +## Learned User Preferences + +- User prefers fresh, tool-backed verification for setup/configuration work and often asks agents to prove changes actually work. +- User wants repo-native project tooling used for codebase review, planning, and durable decision capture when available. +- User prefers local checkout tooling over global installs during active development, so tool behavior reflects the current branch. +- For unshipped PR branch work, replace in-progress designs directly rather than adding compatibility shims for old branch-only behavior. +- When the user asks to remember preferences or decisions, persist concise durable facts using the project memory system when available. + +## Workspace Guidance + +- Keep persistent guidance general and durable; avoid recording transient branch state, temporary schema numbers, or moment-in-time tool status here. +- Store detailed implementation decisions in the project memory system or PR docs instead of expanding this file with narrow session notes. ## Prefer tokensave MCP tools diff --git a/scripts/tokensave-dev-mcp.sh b/scripts/tokensave-dev-mcp.sh new file mode 100755 index 0000000000..b0f4905444 --- /dev/null +++ b/scripts/tokensave-dev-mcp.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Development MCP launcher for Cursor. +# +# Stdio MCP servers cannot hot-reload their tool definitions after Cursor has +# connected. This wrapper keeps development fast by running the current +# worktree source with cargo, so restarting/reconnecting the MCP server in +# Cursor picks up code changes without installing a new global binary. + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +MANIFEST="$REPO_ROOT/Cargo.toml" +DEFAULT_PROJECT_ROOT="$REPO_ROOT" +PROJECT_ROOT="${TOKENSAVE_DEV_PROJECT_ROOT:-$DEFAULT_PROJECT_ROOT}" + +exec cargo run --quiet --manifest-path "$MANIFEST" -- serve --path "$PROJECT_ROOT" "$@" diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 51b4b5fe5b..0c8888b082 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -40,7 +40,7 @@ impl AgentIntegration for CodexIntegration { std::fs::create_dir_all(&codex_dir).ok(); let config_path = codex_dir.join("config.toml"); - install_mcp_server(&config_path, &ctx.tokensave_bin)?; + install_mcp_server(&config_path, &ctx.tokensave_bin, false, true)?; let agents_md = codex_dir.join("AGENTS.md"); install_prompt_rules(&agents_md)?; @@ -62,7 +62,12 @@ impl AgentIntegration for CodexIntegration { fn install_local(&self, ctx: &InstallContext, project_path: &Path) -> Result<()> { let codex_dir = project_path.join(".codex"); std::fs::create_dir_all(&codex_dir).ok(); - install_mcp_server(&codex_dir.join("config.toml"), &ctx.tokensave_bin)?; + install_mcp_server( + &codex_dir.join("config.toml"), + &ctx.tokensave_bin, + true, + false, + )?; install_prompt_rules(&project_path.join("AGENTS.md"))?; install_hooks(&codex_dir.join("hooks.json"), &ctx.tokensave_bin)?; print_hook_trust_guidance(); @@ -88,11 +93,21 @@ impl AgentIntegration for CodexIntegration { fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) { eprintln!("\n\x1b[1mCodex CLI integration\x1b[0m"); - let codex_dir = ctx.home.join(".codex"); - let config_path = codex_dir.join("config.toml"); - doctor_check_config(dc, &config_path); - doctor_check_prompt(dc, &codex_dir); - doctor_check_hooks(dc, &codex_dir.join("hooks.json")); + let local_codex_dir = ctx.project_path.join(".codex"); + if local_codex_dir.join("config.toml").exists() + || local_codex_dir.join("hooks.json").exists() + || ctx.project_path.join("AGENTS.md").exists() + { + doctor_check_config(dc, &local_codex_dir.join("config.toml")); + doctor_check_prompt_file(dc, &ctx.project_path.join("AGENTS.md")); + doctor_check_hooks(dc, &local_codex_dir.join("hooks.json")); + } else { + let codex_dir = ctx.home.join(".codex"); + let config_path = codex_dir.join("config.toml"); + doctor_check_config(dc, &config_path); + doctor_check_prompt_file(dc, &codex_dir.join("AGENTS.md")); + doctor_check_hooks(dc, &codex_dir.join("hooks.json")); + } } fn is_detected(&self, home: &Path) -> bool { @@ -123,7 +138,12 @@ impl AgentIntegration for CodexIntegration { // --------------------------------------------------------------------------- /// Register MCP server and auto-approve tools in ~/.codex/config.toml. -fn install_mcp_server(config_path: &Path, tokensave_bin: &str) -> Result<()> { +fn install_mcp_server( + config_path: &Path, + tokensave_bin: &str, + is_local_install: bool, + enable_global_db: bool, +) -> Result<()> { let mut config = load_toml_file(config_path)?; // Ensure [mcp_servers.tokensave] exists @@ -146,10 +166,24 @@ fn install_mcp_server(config_path: &Path, tokensave_bin: &str) -> Result<()> { "command".to_string(), toml::Value::String(tokensave_bin.to_string()), ); - server_table.insert( - "args".to_string(), - toml::Value::Array(vec![toml::Value::String("serve".to_string())]), - ); + let args = if is_local_install { + vec![ + toml::Value::String("serve".to_string()), + toml::Value::String("--path".to_string()), + toml::Value::String(".".to_string()), + ] + } else { + vec![toml::Value::String("serve".to_string())] + }; + server_table.insert("args".to_string(), toml::Value::Array(args)); + if enable_global_db { + let mut env_table = toml::map::Map::new(); + env_table.insert( + "TOKENSAVE_ENABLE_GLOBAL_DB".to_string(), + toml::Value::String("1".to_string()), + ); + server_table.insert("env".to_string(), toml::Value::Table(env_table)); + } // Auto-approve all tokensave tools so Codex doesn't prompt for each one let mut tools_table = toml::map::Map::new(); @@ -540,19 +574,24 @@ fn doctor_check_config(dc: &mut DoctorCounters, config_path: &Path) { } /// Check AGENTS.md contains tokensave rules. -fn doctor_check_prompt(dc: &mut DoctorCounters, codex_dir: &Path) { - let agents_md = codex_dir.join("AGENTS.md"); +fn doctor_check_prompt_file(dc: &mut DoctorCounters, agents_md: &Path) { if agents_md.exists() { let has_rules = std::fs::read_to_string(&agents_md) .unwrap_or_default() .contains("tokensave"); if has_rules { - dc.pass("AGENTS.md contains tokensave rules"); + dc.pass(&format!( + "AGENTS.md contains tokensave rules in {}", + agents_md.display() + )); } else { - dc.fail("AGENTS.md missing tokensave rules — run `tokensave install --agent codex`"); + dc.fail(&format!( + "AGENTS.md missing tokensave rules in {} — run `tokensave install --local --agent codex` or `tokensave install --agent codex`", + agents_md.display() + )); } } else { - dc.warn("~/.codex/AGENTS.md does not exist"); + dc.warn(&format!("{} does not exist", agents_md.display())); } } @@ -567,22 +606,22 @@ fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { return; } let hooks = super::load_json_file(hooks_path); - let has_session_start = hooks["hooks"]["SessionStart"] - .as_array() - .is_some_and(|groups| { - groups.iter().any(|group| { - group["hooks"].as_array().is_some_and(|handlers| { - handlers.iter().any(|h| { - h["command"] - .as_str() - .is_some_and(|c| c.contains("hook-codex-session-start")) - }) - }) - }) - }); - if has_session_start { + let expected = [ + ("SessionStart", "hook-codex-session-start"), + ("UserPromptSubmit", "hook-codex-user-prompt-submit"), + ("SubagentStart", "hook-codex-subagent-start"), + ("PostToolUse", "hook-codex-post-tool-use"), + ]; + let missing: Vec<&str> = expected + .iter() + .filter_map(|(event, command)| { + (!codex_hook_present(&hooks, event, command)).then_some(*event) + }) + .collect(); + if missing.is_empty() { dc.pass(&format!( - "Lifecycle hooks registered in {}", + "All {} Codex lifecycle hooks registered in {}", + expected.len(), hooks_path.display() )); dc.info( @@ -590,8 +629,23 @@ fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { ); } else { dc.warn(&format!( - "tokensave hooks NOT registered in {} — run `tokensave install --agent codex`", - hooks_path.display() + "tokensave hook(s) missing for {} in {} — run `tokensave install --local --agent codex` or `tokensave install --agent codex`", + missing.join(", "), + hooks_path.display(), )); } } + +fn codex_hook_present(hooks: &serde_json::Value, event: &str, command: &str) -> bool { + hooks["hooks"][event].as_array().is_some_and(|groups| { + groups.iter().any(|group| { + group["hooks"].as_array().is_some_and(|handlers| { + handlers.iter().any(|h| { + h["command"] + .as_str() + .is_some_and(|value| value.contains(command)) + }) + }) + }) + }) +} diff --git a/src/agents/copilot.rs b/src/agents/copilot.rs index b08a599eb3..868fbc01ff 100644 --- a/src/agents/copilot.rs +++ b/src/agents/copilot.rs @@ -394,8 +394,15 @@ fn install_prompt_rules(instructions_path: &Path) -> Result<()> { faster than file reads.\n\n\ If a code analysis question cannot be fully answered by tokensave MCP tools, \ try querying the SQLite database directly at `.tokensave/tokensave.db` \ - (tables: `nodes`, `edges`, `files`). Use SQL to answer complex structural queries \ - that go beyond what the built-in tools expose.\n" + (tables: `nodes`, `edges`, `files`, `memory_facts`, `memory_entities`, \ + `memory_feedback_events`). Use SQL to answer complex structural queries \ + that go beyond what the built-in tools expose.\n\n\ + For durable project/user facts, prefer `tokensave_fact_store`, \ + `tokensave_fact_feedback`, and `tokensave_memory_status` over ad-hoc notes. \ + Do not store secrets, credentials, or unnecessary PII in persistent facts.\n\n\ + If you find a gap where tokensave could answer a question natively, propose opening \ + an issue at https://github.com/aovestdipaperino/tokensave. Remind the user to strip \ + sensitive or proprietary code from any issue text before submitting.\n" ) .map_err(|e| crate::errors::TokenSaveError::Config { message: format!("failed to write {}: {e}", instructions_path.display()), diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 931de78d0a..40a858663b 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -11,8 +11,8 @@ use crate::errors::{Result, TokenSaveError}; use super::{ backup_and_write_json, backup_config_file, load_json_file, load_json_file_strict, - load_jsonc_file_strict, read_only_tool_names, safe_write_json_file, AgentIntegration, - DoctorCounters, HealthcheckContext, InstallContext, + load_jsonc_file_strict, safe_write_json_file, tool_names, AgentIntegration, DoctorCounters, + HealthcheckContext, InstallContext, }; /// Cursor agent. @@ -28,7 +28,12 @@ impl AgentIntegration for CursorIntegration { } fn install(&self, ctx: &InstallContext) -> Result<()> { - install_mcp_server(&ctx.home.join(".cursor/mcp.json"), &ctx.tokensave_bin)?; + install_mcp_server( + &ctx.home.join(".cursor/mcp.json"), + &ctx.tokensave_bin, + false, + true, + )?; eprintln!(); eprintln!("Setup complete. Next steps:"); @@ -43,7 +48,12 @@ impl AgentIntegration for CursorIntegration { fn install_local(&self, ctx: &InstallContext, project_path: &Path) -> Result<()> { let cursor_dir = project_path.join(".cursor"); - install_mcp_server(&cursor_dir.join("mcp.json"), &ctx.tokensave_bin)?; + install_mcp_server( + &cursor_dir.join("mcp.json"), + &ctx.tokensave_bin, + true, + false, + )?; 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) @@ -61,7 +71,16 @@ impl AgentIntegration for CursorIntegration { fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) { eprintln!("\n\x1b[1mCursor integration\x1b[0m"); - doctor_check_settings(dc, &ctx.home); + let project_cursor = ctx.project_path.join(".cursor"); + if project_cursor.join("mcp.json").exists() + || project_cursor.join("hooks.json").exists() + || project_cursor.join("permissions.json").exists() + || project_cursor.join("rules/tokensave.mdc").exists() + { + doctor_check_local_settings(dc, &project_cursor); + } else { + doctor_check_settings(dc, &ctx.home); + } } fn is_detected(&self, home: &Path) -> bool { @@ -88,7 +107,12 @@ impl AgentIntegration for CursorIntegration { // Uninstall helpers // --------------------------------------------------------------------------- -fn install_mcp_server(mcp_path: &Path, tokensave_bin: &str) -> Result<()> { +fn install_mcp_server( + mcp_path: &Path, + tokensave_bin: &str, + is_local_install: bool, + enable_global_db: bool, +) -> Result<()> { if let Some(parent) = mcp_path.parent() { std::fs::create_dir_all(parent).ok(); } @@ -103,11 +127,18 @@ fn install_mcp_server(mcp_path: &Path, tokensave_bin: &str) -> Result<()> { return Err(e); } }; - settings["mcpServers"]["tokensave"] = json!({ + let mut server = json!({ "type": "stdio", "command": tokensave_bin, "args": ["serve"] }); + if is_local_install { + server["args"] = json!(["serve", "--path", "."]); + } + if enable_global_db { + server["env"]["TOKENSAVE_ENABLE_GLOBAL_DB"] = json!("1"); + } + settings["mcpServers"]["tokensave"] = server; safe_write_json_file(mcp_path, &settings, backup.as_deref())?; eprintln!( @@ -149,16 +180,24 @@ fn install_permissions(permissions_path: &Path) -> Result<()> { } }; + let tokensave_tools = tool_names(); + let known_tokensave_entries: std::collections::HashSet = tokensave_tools + .iter() + .map(|tool| format!("tokensave:{tool}")) + .collect(); let existing = permissions["mcpAllowlist"] .as_array() .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(str::to_string)) + .filter(|entry| { + !entry.starts_with("tokensave:") || known_tokensave_entries.contains(entry) + }) .collect::>() }) .unwrap_or_default(); let mut allow = existing; - for tool in read_only_tool_names() { + for tool in tokensave_tools { let entry = format!("tokensave:{tool}"); if !allow.iter().any(|existing| existing == &entry) { allow.push(entry); @@ -359,10 +398,30 @@ fn uninstall_mcp_server(mcp_path: &Path) { /// Check ~/.cursor/mcp.json has tokensave MCP server registered. fn doctor_check_settings(dc: &mut DoctorCounters, home: &Path) { let mcp_path = home.join(".cursor/mcp.json"); + doctor_check_mcp_server( + dc, + &mcp_path, + "`tokensave install --agent cursor`", + "global", + ); +} +fn doctor_check_local_settings(dc: &mut DoctorCounters, cursor_dir: &Path) { + doctor_check_mcp_server( + dc, + &cursor_dir.join("mcp.json"), + "`tokensave install --local --agent cursor`", + "project-local", + ); + doctor_check_permissions(dc, &cursor_dir.join("permissions.json")); + doctor_check_hooks(dc, &cursor_dir.join("hooks.json")); + doctor_check_rule(dc, &cursor_dir.join("rules/tokensave.mdc")); +} + +fn doctor_check_mcp_server(dc: &mut DoctorCounters, mcp_path: &Path, fix: &str, label: &str) { if !mcp_path.exists() { dc.warn(&format!( - "{} not found — run `tokensave install --agent cursor` if you use Cursor", + "{} not found — run {fix} if you use Cursor", mcp_path.display() )); return; @@ -375,8 +434,120 @@ fn doctor_check_settings(dc: &mut DoctorCounters, home: &Path) { dc.pass(&format!("MCP server registered in {}", mcp_path.display())); } else { dc.fail(&format!( - "MCP server NOT registered in {} — run `tokensave install --agent cursor`", + "{label} MCP server NOT registered in {} — run {fix}", mcp_path.display() )); } } + +fn doctor_check_permissions(dc: &mut DoctorCounters, permissions_path: &Path) { + if !permissions_path.exists() { + dc.warn(&format!( + "{} not found — run `tokensave install --local --agent cursor`", + permissions_path.display() + )); + return; + } + let permissions = load_jsonc_file_strict(permissions_path).unwrap_or_else(|e| { + dc.fail(&format!("{e}")); + json!({}) + }); + let installed: std::collections::HashSet<&str> = permissions["mcpAllowlist"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + let expected: Vec = tool_names() + .into_iter() + .map(|tool| format!("tokensave:{tool}")) + .collect(); + let missing = expected + .iter() + .filter(|entry| !installed.contains(entry.as_str())) + .count(); + let stale = installed + .iter() + .filter(|entry| { + entry.starts_with("tokensave:") && !expected.iter().any(|expected| expected == **entry) + }) + .count(); + if missing == 0 && stale == 0 { + dc.pass(&format!( + "All {} Cursor MCP permissions granted in {}", + expected.len(), + permissions_path.display() + )); + } else { + dc.fail(&format!( + "{missing} Cursor MCP permission(s) missing and {stale} stale — run `tokensave install --local --agent cursor`" + )); + } +} + +fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { + if !hooks_path.exists() { + dc.warn(&format!( + "{} not found — run `tokensave install --local --agent cursor`", + hooks_path.display() + )); + return; + } + let hooks = load_jsonc_file_strict(hooks_path).unwrap_or_else(|e| { + dc.fail(&format!("{e}")); + json!({}) + }); + let expected = [ + ("sessionStart", "hook-cursor-session-start"), + ("subagentStart", "hook-cursor-subagent-start"), + ("beforeSubmitPrompt", "hook-cursor-before-submit-prompt"), + ("afterFileEdit", "hook-cursor-after-file-edit"), + ("afterShellExecution", "hook-cursor-after-shell"), + ("workspaceOpen", "hook-cursor-workspace-open"), + ]; + let missing: Vec<&str> = expected + .iter() + .filter_map(|(event, command)| { + let has = hooks["hooks"][*event].as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + entry["command"] + .as_str() + .is_some_and(|value| value.contains(command)) + }) + }); + (!has).then_some(*event) + }) + .collect(); + if missing.is_empty() { + dc.pass(&format!( + "All {} Cursor lifecycle hooks registered in {}", + expected.len(), + hooks_path.display() + )); + } else { + dc.fail(&format!( + "Cursor hook(s) missing for {} — run `tokensave install --local --agent cursor`", + missing.join(", ") + )); + } +} + +fn doctor_check_rule(dc: &mut DoctorCounters, rule_path: &Path) { + if !rule_path.exists() { + dc.warn(&format!( + "{} not found — run `tokensave install --local --agent cursor`", + rule_path.display() + )); + return; + } + let contents = std::fs::read_to_string(rule_path).unwrap_or_default(); + if contents.contains("alwaysApply: true") && contents.contains("tokensave MCP tools") { + dc.pass(&format!( + "Cursor tokensave rule active in {}", + rule_path.display() + )); + } else { + dc.fail(&format!( + "Cursor tokensave rule is incomplete in {} — run `tokensave install --local --agent cursor`", + rule_path.display() + )); + } +} diff --git a/src/agents/vibe.rs b/src/agents/vibe.rs index a2d6763a18..9f2025e71d 100644 --- a/src/agents/vibe.rs +++ b/src/agents/vibe.rs @@ -186,8 +186,15 @@ fn install_prompt_rules(prompt_path: &Path) -> Result<()> { faster than file reads.\n\n\ If a code analysis question cannot be fully answered by tokensave MCP tools, \ try querying the SQLite database directly at `.tokensave/tokensave.db` \ - (tables: `nodes`, `edges`, `files`). Use SQL to answer complex structural queries \ + (tables: `nodes`, `edges`, `files`, `memory_facts`, `memory_entities`, \ + `memory_feedback_events`). Use SQL to answer complex structural queries \ that go beyond what the built-in tools expose.\n\n\ + For durable project/user facts, prefer `tokensave_fact_store`, \ + `tokensave_fact_feedback`, and `tokensave_memory_status` over ad-hoc notes. \ + Do not store secrets, credentials, or unnecessary PII in persistent facts.\n\n\ + If you find a gap where tokensave could answer a question natively, propose opening \ + an issue at https://github.com/aovestdipaperino/tokensave. Remind the user to strip \ + sensitive or proprietary code from any issue text before submitting.\n\n\ When a tokensave tool result contains a `tokensave_metrics:` line, report the \ savings to the user (e.g. \"TokenSave'd ~N tokens\"). Never silently omit this.\n" ) diff --git a/src/global.rs b/src/global.rs index 58ceed84be..f074d240dd 100644 --- a/src/global.rs +++ b/src/global.rs @@ -6,6 +6,9 @@ use tokensave::tokensave::TokenSave; /// Best-effort: register this project in the user-level global DB and /// accumulate the token-saved delta into the pending upload counter. pub(crate) async fn update_global_db(cg: &TokenSave) { + if !tokensave::user_config::UserConfig::exists() { + return; + } let tokens = cg.get_tokens_saved().await.unwrap_or(0); if let Some(gdb) = tokensave::global_db::GlobalDb::open().await { let previous = gdb.get_project_tokens(cg.project_root()).await; @@ -15,7 +18,7 @@ pub(crate) async fn update_global_db(cg: &TokenSave) { if tokens > previous { let mut config = tokensave::user_config::UserConfig::load(); config.pending_upload += tokens - previous; - config.save(); + config.save_if_exists(); } } } @@ -71,7 +74,7 @@ pub(crate) fn check_for_update( } else if let Some(v) = tokensave::cloud::fetch_latest_version() { config.cached_latest_version = v.clone(); config.last_version_check_at = now; - config.save(); + config.save_if_exists(); v } else { return; @@ -92,7 +95,7 @@ pub(crate) fn check_for_update( ); if !skip_suppression { config.last_version_warning_at = now; - config.save(); + config.save_if_exists(); } } } diff --git a/src/hooks.rs b/src/hooks.rs index 4d3456cad1..8c6bfe35b2 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -122,6 +122,7 @@ pub fn hook_cursor_subagent_start() -> i32 { pub async fn hook_cursor_before_submit_prompt() -> i32 { let event = read_stdin_to_string(); reset_counter_for_cursor_event(&event).await; + ingest_cursor_transcript_for_event(&event).await; println!("{}", serde_json::json!({ "continue": true })); 0 } @@ -1161,6 +1162,16 @@ async fn reset_counter_for_cursor_event(event_json: &str) { } } +async fn ingest_cursor_transcript_for_event(event_json: &str) { + let Some(project_root) = cursor_project_root_from_event(event_json) else { + return; + }; + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return; + }; + let _ = crate::sessions::cursor::ingest_cursor_transcript_event(event_json, &db).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(()); diff --git a/src/main.rs b/src/main.rs index d3713f19be..998823acef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { tokensave::extraction_worker::run_worker(); } + let skip_startup_maintenance = should_skip_startup_maintenance(&command); let skip_agent_install_maintenance = should_skip_agent_install_maintenance(&command); // First-run notice (check BEFORE any config save creates the file) @@ -137,14 +138,14 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { // makes a synchronous HTTP call (#84) which can add seconds to // `tokensave serve` startup on slow networks — long enough to blow the // MCP client's 30 s `initialize` timeout. - if !skip_agent_install_maintenance { + if !skip_startup_maintenance { global::try_flush(&mut user_config, is_force_flush); } if !is_local_install_command(&command) { - user_config.save(); + user_config.save_if_exists(); } - if is_first_run && !skip_agent_install_maintenance { + if is_first_run && !skip_startup_maintenance { eprintln!( "note: tokensave uploads anonymous token-saved counts to a worldwide counter.\n\ \x20 Run `tokensave disable-upload-counter` to opt out." @@ -245,7 +246,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { let mut config = tokensave::user_config::UserConfig::load(); config.cached_latest_version = latest.clone(); config.last_version_check_at = now; - config.save(); + config.save_if_exists(); if tokensave::cloud::is_newer_version(current_version, &latest) && now - config.last_version_warning_at >= 900 { @@ -254,7 +255,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { current_version, latest ); config.last_version_warning_at = now; - config.save(); + config.save_if_exists(); } } } @@ -359,7 +360,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { let mut config = tokensave::user_config::UserConfig::load(); config.cached_latest_version = latest.clone(); config.last_version_check_at = now; - config.save(); + config.save_if_exists(); if tokensave::cloud::is_newer_version(current_version, &latest) && now - config.last_version_warning_at >= 900 { @@ -368,7 +369,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { current_version, latest ); config.last_version_warning_at = now; - config.save(); + config.save_if_exists(); } } } @@ -444,7 +445,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { } else if let Some(total) = tokensave::cloud::fetch_worldwide_total() { config.last_worldwide_total = total; config.last_worldwide_fetch_at = now; - config.save(); + config.save_if_exists(); Some(total) } else if config.last_worldwide_total > 0 { Some(config.last_worldwide_total) // fallback to cache @@ -459,7 +460,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { if !fresh.is_empty() { config.cached_country_flags = fresh.clone(); config.last_flags_fetch_at = now; - config.save(); + config.save_if_exists(); } if fresh.is_empty() && !config.cached_country_flags.is_empty() { config.cached_country_flags.clone() @@ -1198,7 +1199,7 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { Ok(()) } -fn should_skip_agent_install_maintenance(command: &Commands) -> bool { +fn should_skip_startup_maintenance(command: &Commands) -> bool { matches!( command, Commands::Install { .. } @@ -1226,41 +1227,47 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { ) } +fn should_skip_agent_install_maintenance(_command: &Commands) -> bool { + // Never mutate user-profile agent configs as an implicit startup side + // effect. Global/profile installs remain available through explicit + // `tokensave install`, `tokensave reinstall`, and `tokensave uninstall` + // command handling below; project-local setup remains `install --local`. + true +} + fn is_local_install_command(command: &Commands) -> bool { matches!(command, Commands::Install { local: true, .. }) } #[cfg(test)] mod startup_tests { - use super::{should_skip_agent_install_maintenance, Commands}; + use super::{should_skip_agent_install_maintenance, should_skip_startup_maintenance, Commands}; #[test] - fn doctor_skips_agent_install_maintenance() { + fn doctor_skips_startup_maintenance() { let command = Commands::Doctor { agent: Some("kiro".to_string()), }; - assert!(should_skip_agent_install_maintenance(&command)); + assert!(should_skip_startup_maintenance(&command)); } #[test] - fn explicit_agent_config_commands_skip_agent_install_maintenance() { - assert!(should_skip_agent_install_maintenance(&Commands::Install { + fn explicit_agent_config_commands_skip_startup_maintenance() { + assert!(should_skip_startup_maintenance(&Commands::Install { agent: Some("kiro".to_string()), local: false, profile: None, })); - assert!(should_skip_agent_install_maintenance(&Commands::Reinstall)); - assert!(should_skip_agent_install_maintenance( - &Commands::Uninstall { - agent: Some("kiro".to_string()), - profile: None, - } - )); + assert!(should_skip_startup_maintenance(&Commands::Reinstall)); + assert!(should_skip_startup_maintenance(&Commands::Uninstall { + agent: Some("kiro".to_string()), + profile: None, + })); } #[test] - fn normal_commands_keep_agent_install_maintenance() { - assert!(!should_skip_agent_install_maintenance(&Commands::Status { + fn normal_commands_keep_startup_maintenance() { + assert!(!should_skip_startup_maintenance(&Commands::Status { path: None, json: false, short: false, @@ -1270,12 +1277,30 @@ mod startup_tests { } #[test] - fn serve_skips_agent_install_maintenance() { + fn all_commands_skip_implicit_agent_install_maintenance() { + assert!(should_skip_agent_install_maintenance(&Commands::Tool { + name: Some("message_search".to_string()), + args: Vec::new(), + })); + assert!(should_skip_agent_install_maintenance(&Commands::Init { + path: None, + skip_folders: Vec::new(), + })); + assert!(should_skip_agent_install_maintenance(&Commands::Install { + agent: Some("cursor".to_string()), + local: false, + profile: None, + })); + assert!(should_skip_agent_install_maintenance(&Commands::Reinstall)); + } + + #[test] + fn serve_skips_startup_maintenance() { // `tokensave serve` is the MCP hot path with a 30 s client-side // `initialize` timeout (#84). Pre-serve maintenance work // (worldwide-counter flush, install-stale check, silent reinstall) // must NOT run on this path. - assert!(should_skip_agent_install_maintenance(&Commands::Serve { + assert!(should_skip_startup_maintenance(&Commands::Serve { path: None, timings: false, })); diff --git a/src/user_config.rs b/src/user_config.rs index 2753d20a4a..86f84722cd 100644 --- a/src/user_config.rs +++ b/src/user_config.rs @@ -159,10 +159,26 @@ impl UserConfig { std::fs::write(&path, contents).is_ok() } + /// Saves only when `~/.tokensave/config.toml` already exists. + /// + /// This lets repo-local commands update an existing user profile without + /// creating one as an incidental side effect. + pub fn save_if_exists(&self) -> bool { + if !Self::exists() { + return false; + } + self.save() + } + /// Returns true if this is a fresh config (file did not exist before). pub fn is_fresh() -> bool { config_path().is_none_or(|p| !p.exists()) } + + /// Returns true when the user-level config file already exists. + pub fn exists() -> bool { + config_path().is_some_and(|p| p.exists()) + } } /// Parse a human-readable duration string like "15s" or "1m" into a Duration. diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 1dbf7e316d..a2f94fdfb4 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -208,12 +208,16 @@ fn test_local_install_cursor_writes_project_config_only() { assert_command_is_tokensave(&config, &["mcpServers", "tokensave", "command"]); assert_eq!( config["mcpServers"]["tokensave"]["args"], - serde_json::json!(["serve"]) + serde_json::json!(["serve", "--path", "."]) ); assert_eq!( config["mcpServers"]["tokensave"]["type"], serde_json::json!("stdio") ); + assert!( + config["mcpServers"]["tokensave"].get("env").is_none(), + "local Cursor config should not need env flags for repo-local mode" + ); let rule_path = project.path().join(".cursor/rules/tokensave.mdc"); assert!(rule_path.exists(), "Cursor local rule should exist"); @@ -232,25 +236,17 @@ fn test_local_install_cursor_writes_project_config_only() { .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() { + for tool in 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}" + "Cursor permissions should allow MCP tool {expected}" ); } + assert!( + !allow_strs.contains(&"tokensave:tokensave_session_recall"), + "Cursor permissions should not keep removed legacy memory tools" + ); let hooks_path = project.path().join(".cursor/hooks.json"); assert!( @@ -343,6 +339,46 @@ fn test_local_install_cursor_writes_project_config_only() { ); } +#[test] +fn test_local_install_cursor_refreshes_memory_permissions() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let cursor_dir = project.path().join(".cursor"); + std::fs::create_dir_all(&cursor_dir).unwrap(); + std::fs::write( + cursor_dir.join("permissions.json"), + r#"{ + "mcpAllowlist": [ + "other:custom_tool", + "tokensave:tokensave_session_recall", + "tokensave:tokensave_str_replace" + ] +} +"#, + ) + .unwrap(); + + assert_local_install_success("cursor", project.path(), home.path()); + + let permissions = read_json(&cursor_dir.join("permissions.json")); + 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(); + assert!(allow_strs.contains(&"other:custom_tool")); + for tool in tool_names() { + let expected = format!("tokensave:{tool}"); + assert!( + allow_strs.contains(&expected.as_str()), + "Cursor permissions should refresh every current tokensave tool {expected}" + ); + } + assert!( + !allow_strs.contains(&"tokensave:tokensave_session_recall"), + "removed legacy memory permissions should be pruned" + ); +} + #[test] fn test_hermes_local_install_writes_profile_plugin() { let home = TempDir::new().unwrap(); @@ -1006,6 +1042,16 @@ fn test_claude_install_creates_config() { settings["permissions"]["allow"].is_array(), "permissions.allow should be an array" ); + let allow = settings["permissions"]["allow"].as_array().unwrap(); + assert!(allow + .iter() + .any(|v| { v.as_str() == Some("mcp__tokensave__tokensave_fact_store") })); + assert!(allow + .iter() + .any(|v| { v.as_str() == Some("mcp__tokensave__tokensave_fact_feedback") })); + assert!(allow + .iter() + .any(|v| { v.as_str() == Some("mcp__tokensave__tokensave_memory_status") })); // Check CLAUDE.md exists with tokensave rules let claude_md = home.join(".claude/CLAUDE.md"); @@ -1071,10 +1117,25 @@ fn test_codex_install_creates_config() { content.contains("[mcp_servers.tokensave]"), "config.toml should contain [mcp_servers.tokensave]" ); + assert!( + content.contains("TOKENSAVE_ENABLE_GLOBAL_DB = \"1\""), + "global Codex config should opt into user-level global accounting" + ); assert!( content.contains("\"serve\""), "config.toml should contain \"serve\" in args" ); + for tool in tool_names() { + let section = format!("[mcp_servers.tokensave.tools.{tool}]"); + let section_start = content.find(§ion).unwrap_or_else(|| { + panic!("Codex config should include auto-approval section {section}") + }); + let after_section = &content[section_start..]; + assert!( + after_section.contains("approval_mode = \"auto\""), + "Codex should auto-approve tokensave tool {tool}" + ); + } // Check AGENTS.md let agents_md = home.join(".codex/AGENTS.md"); @@ -1170,6 +1231,18 @@ fn test_codex_local_install_writes_hooks() { assert_local_install_success("codex", project.path(), home.path()); + let config_path = project.path().join(".codex/config.toml"); + let config = std::fs::read_to_string(&config_path).unwrap(); + assert!( + config.contains("args = [\n \"serve\",\n \"--path\",\n \".\",\n]"), + "local Codex config should pin serve to the project root with --path ." + ); + assert!( + !config.contains("TOKENSAVE_DISABLE_GLOBAL_DB") + && !config.contains("TOKENSAVE_ENABLE_GLOBAL_DB"), + "local Codex config should not need env flags for repo-local mode" + ); + let hooks_path = project.path().join(".codex/hooks.json"); assert!( hooks_path.exists(), @@ -1366,6 +1439,10 @@ fn test_cursor_install_creates_config() { let content: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&mcp_path).unwrap()).unwrap(); assert!(content["mcpServers"]["tokensave"].is_object()); + assert_eq!( + content["mcpServers"]["tokensave"]["env"]["TOKENSAVE_ENABLE_GLOBAL_DB"], + serde_json::json!("1") + ); } #[test] @@ -1496,6 +1573,12 @@ fn test_copilot_install_creates_config() { let cli_content: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cli_config).unwrap()).unwrap(); assert!(cli_content["mcpServers"]["tokensave"].is_object()); + + let cli_prompt = home.join(".copilot/copilot-instructions.md"); + let prompt = std::fs::read_to_string(&cli_prompt).unwrap(); + assert!(prompt.contains("tokensave_fact_store")); + assert!(prompt.contains("memory_facts")); + assert!(prompt.contains("sensitive or proprietary code")); } #[test] @@ -1532,6 +1615,9 @@ fn test_vibe_install_creates_config() { ); let prompt = std::fs::read_to_string(&prompt_path).unwrap(); assert!(prompt.contains("tokensave")); + assert!(prompt.contains("tokensave_fact_store")); + assert!(prompt.contains("memory_facts")); + assert!(prompt.contains("sensitive or proprietary code")); } // --------------------------------------------------------------------------- @@ -2194,6 +2280,24 @@ fn test_healthcheck_codex_after_install() { ); } +#[test] +fn test_healthcheck_codex_local_install_checks_project_config() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + assert_local_install_success("codex", project.path(), home.path()); + + let mut dc = DoctorCounters::new(); + let hctx = HealthcheckContext { + home: home.path().to_path_buf(), + project_path: project.path().to_path_buf(), + }; + CodexIntegration.healthcheck(&mut dc, &hctx); + assert_eq!( + dc.issues, 0, + "local Codex healthcheck should pass without global ~/.codex config" + ); +} + #[test] fn test_healthcheck_cursor_clean_install() { let dir = TempDir::new().unwrap(); @@ -2210,6 +2314,24 @@ fn test_healthcheck_cursor_clean_install() { assert_eq!(dc.issues, 0, "clean Cursor install should have no issues"); } +#[test] +fn test_healthcheck_cursor_local_install_checks_project_config() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + assert_local_install_success("cursor", project.path(), home.path()); + + let mut dc = DoctorCounters::new(); + let hctx = HealthcheckContext { + home: home.path().to_path_buf(), + project_path: project.path().to_path_buf(), + }; + CursorIntegration.healthcheck(&mut dc, &hctx); + assert_eq!( + dc.issues, 0, + "local Cursor healthcheck should pass without global ~/.cursor config" + ); +} + #[test] fn test_healthcheck_opencode_clean_install() { let dir = TempDir::new().unwrap(); @@ -2865,10 +2987,13 @@ fn test_read_only_tool_names_excludes_mutating_tools() { "tokensave_multi_str_replace", "tokensave_insert_at", "tokensave_ast_grep_rewrite", + "tokensave_replace_symbol", + "tokensave_insert_at_symbol", + "tokensave_run_affected_tests", "tokensave_session_start", "tokensave_session_end", - "tokensave_record_decision", - "tokensave_record_code_area", + "tokensave_fact_store", + "tokensave_fact_feedback", ] { assert!( !read_only_set.contains(mutating), From 6cf11b2447e8f5ffdd5219a608071256aca177c5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 01:10:50 +0200 Subject: [PATCH 02/10] fix(install): keep cursor base independent of session ingest --- src/hooks.rs | 11 ----------- tests/agent_test.rs | 11 +++-------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index 8c6bfe35b2..4d3456cad1 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -122,7 +122,6 @@ pub fn hook_cursor_subagent_start() -> i32 { pub async fn hook_cursor_before_submit_prompt() -> i32 { let event = read_stdin_to_string(); reset_counter_for_cursor_event(&event).await; - ingest_cursor_transcript_for_event(&event).await; println!("{}", serde_json::json!({ "continue": true })); 0 } @@ -1162,16 +1161,6 @@ async fn reset_counter_for_cursor_event(event_json: &str) { } } -async fn ingest_cursor_transcript_for_event(event_json: &str) { - let Some(project_root) = cursor_project_root_from_event(event_json) else { - return; - }; - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return; - }; - let _ = crate::sessions::cursor::ingest_cursor_transcript_event(event_json, &db).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(()); diff --git a/tests/agent_test.rs b/tests/agent_test.rs index a2f94fdfb4..983996a792 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -243,11 +243,6 @@ fn test_local_install_cursor_writes_project_config_only() { "Cursor permissions should allow MCP tool {expected}" ); } - assert!( - !allow_strs.contains(&"tokensave:tokensave_session_recall"), - "Cursor permissions should not keep removed legacy memory tools" - ); - let hooks_path = project.path().join(".cursor/hooks.json"); assert!( hooks_path.exists(), @@ -350,7 +345,7 @@ fn test_local_install_cursor_refreshes_memory_permissions() { r#"{ "mcpAllowlist": [ "other:custom_tool", - "tokensave:tokensave_session_recall", + "tokensave:tokensave_not_a_real_tool", "tokensave:tokensave_str_replace" ] } @@ -374,8 +369,8 @@ fn test_local_install_cursor_refreshes_memory_permissions() { ); } assert!( - !allow_strs.contains(&"tokensave:tokensave_session_recall"), - "removed legacy memory permissions should be pruned" + !allow_strs.contains(&"tokensave:tokensave_not_a_real_tool"), + "unknown tokensave permissions should be pruned" ); } From 836b6dea29482d0b8d350b7834f6e278a16f5166 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 01:14:27 +0200 Subject: [PATCH 03/10] fix(install): clean local doctor clippy warnings --- src/agents/codex.rs | 2 +- src/agents/cursor.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 0c8888b082..39bebd2cd7 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -576,7 +576,7 @@ fn doctor_check_config(dc: &mut DoctorCounters, config_path: &Path) { /// Check AGENTS.md contains tokensave rules. fn doctor_check_prompt_file(dc: &mut DoctorCounters, agents_md: &Path) { if agents_md.exists() { - let has_rules = std::fs::read_to_string(&agents_md) + let has_rules = std::fs::read_to_string(agents_md) .unwrap_or_default() .contains("tokensave"); if has_rules { diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 40a858663b..828d3613b9 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -427,7 +427,7 @@ fn doctor_check_mcp_server(dc: &mut DoctorCounters, mcp_path: &Path, fix: &str, return; } - let settings = load_json_file(&mcp_path); + let settings = load_json_file(mcp_path); let server = settings.get("mcpServers").and_then(|v| v.get("tokensave")); if server.and_then(|v| v.as_object()).is_some() { From 02e9db461c75a278d424d227eb51a98ba5b7f319 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 01:20:08 +0200 Subject: [PATCH 04/10] test(install): avoid future-tool assumptions in base slice --- tests/agent_test.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 983996a792..4b65b57736 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -1038,15 +1038,13 @@ fn test_claude_install_creates_config() { "permissions.allow should be an array" ); let allow = settings["permissions"]["allow"].as_array().unwrap(); - assert!(allow - .iter() - .any(|v| { v.as_str() == Some("mcp__tokensave__tokensave_fact_store") })); - assert!(allow - .iter() - .any(|v| { v.as_str() == Some("mcp__tokensave__tokensave_fact_feedback") })); - assert!(allow - .iter() - .any(|v| { v.as_str() == Some("mcp__tokensave__tokensave_memory_status") })); + let allow_strs: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect(); + for perm in expected_tool_perms() { + assert!( + allow_strs.contains(&perm.as_str()), + "permissions.allow should contain {perm}" + ); + } // Check CLAUDE.md exists with tokensave rules let claude_md = home.join(".claude/CLAUDE.md"); From 2310ccb6188dd529c45e5021c6b54eaaad7eea79 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 01:34:47 +0200 Subject: [PATCH 05/10] feat(hooks): route agent hints through shared engine --- src/hooks.rs | 197 +++++++++++++++++++++++++++++++++++----- src/hooks/tool_hints.rs | 2 + tests/hooks_test.rs | 10 ++ 3 files changed, 188 insertions(+), 21 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index 4d3456cad1..7e32bbfb62 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -11,6 +11,10 @@ use std::path::{Path, PathBuf}; use serde_json::Value; +pub mod tool_hints; + +use tool_hints::{decide_hint, HintAgent, ToolHint, ToolHintInput}; + const TOKENSAVE_RESEARCH_BLOCK_REASON: &str = "STOP: Use tokensave MCP tools \ (tokensave_context, tokensave_search, tokensave_callees, tokensave_callers, \ tokensave_impact, tokensave_files, tokensave_affected) instead of agents for \ @@ -37,26 +41,50 @@ pub fn hook_pre_tool_use() { /// Takes the raw `TOOL_INPUT` JSON string and returns the JSON decision /// string to print to stdout. pub fn evaluate_hook_decision(tool_input: &str) -> String { - let block_msg = serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": TOKENSAVE_RESEARCH_BLOCK_REASON - } - }); - let parsed: serde_json::Value = serde_json::from_str(tool_input).unwrap_or_else(|_| serde_json::json!({})); + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Claude, + session_id: event_session_id(&parsed), + tool_name: Some("Agent".to_string()), + command: None, + prompt: prompt_like_text(&parsed), + subagent_type: parsed + .get("subagent_type") + .and_then(Value::as_str) + .map(str::to_string), + file_path: None, + hints_enabled: true, + }); + let block_reason = hint.map_or_else( + || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), + |hint| { + format!( + "{}\n\n{}", + TOKENSAVE_RESEARCH_BLOCK_REASON, + format_tool_hint(&hint) + ) + }, + ); + let block_msg = || { + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": block_reason + } + }) + }; // Block Explore agents outright if parsed.get("subagent_type").and_then(|v| v.as_str()) == Some("Explore") { - return block_msg.to_string(); + return block_msg().to_string(); } // Check if the prompt is exploration/research work that tokensave can handle if let Some(prompt) = parsed.get("prompt").and_then(|v| v.as_str()) { if is_code_research_prompt(prompt) { - return block_msg.to_string(); + return block_msg().to_string(); } } @@ -122,7 +150,11 @@ pub fn hook_cursor_subagent_start() -> i32 { 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 })); + let mut output = serde_json::json!({ "continue": true }); + if let Some(hint) = cursor_prompt_hint(&event) { + output["additional_context"] = Value::String(format_tool_hint(&hint)); + } + println!("{output}"); 0 } @@ -207,12 +239,25 @@ pub fn evaluate_cursor_subagent_start(event_json: &str) -> Option { .and_then(Value::as_str) .unwrap_or_default(); + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Cursor, + session_id: event_session_id(&parsed), + tool_name: Some("subagentStart".to_string()), + command: None, + prompt: (!task.is_empty()).then(|| task.to_string()), + subagent_type: (!subagent_type.is_empty()).then(|| subagent_type.to_string()), + file_path: None, + hints_enabled: true, + }); 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 + "user_message": hint + .map_or_else(|| TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), |hint| { + format!("{}\n\n{}", TOKENSAVE_RESEARCH_BLOCK_REASON, format_tool_hint(&hint)) + }) }) .to_string(), ); @@ -830,7 +875,10 @@ pub async fn hook_codex_user_prompt_submit() -> i32 { let event = read_stdin_to_string(); let root = codex_project_root_from_event(&event); reset_counter_for_codex_event(&event).await; - let context = session_steering_context_for_root(root.as_deref()).await; + let mut context = session_steering_context_for_root(root.as_deref()).await; + if let Some(hint) = codex_prompt_hint(&event) { + append_tool_hint(&mut context, &hint); + } println!( "{}", codex_additional_context_json("UserPromptSubmit", &context) @@ -897,12 +945,29 @@ pub fn evaluate_codex_subagent_start(event_json: &str) -> Option { .and_then(Value::as_str) .unwrap_or_default(); + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Codex, + session_id: event_session_id(&parsed), + tool_name: Some("SubagentStart".to_string()), + command: None, + prompt: (!task.is_empty()).then(|| task.to_string()), + subagent_type: (!agent_type.is_empty()).then(|| agent_type.to_string()), + file_path: None, + hints_enabled: true, + }); let is_explore = agent_type.eq_ignore_ascii_case("explore"); if is_explore || is_code_research_prompt(task) { - return Some(codex_additional_context_json( - "SubagentStart", - TOKENSAVE_RESEARCH_BLOCK_REASON, - )); + let context = hint.map_or_else( + || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), + |hint| { + format!( + "{}\n\n{}", + TOKENSAVE_RESEARCH_BLOCK_REASON, + format_tool_hint(&hint) + ) + }, + ); + return Some(codex_additional_context_json("SubagentStart", &context)); } None } @@ -1035,15 +1100,35 @@ async fn reset_counter_for_codex_event(event_json: &str) { /// Returns a block reason only for Kiro delegation/subagent tool calls whose /// task text looks like codebase research that tokensave MCP tools should /// answer first. -pub fn evaluate_kiro_pre_tool_use(event_json: &str) -> Option<&'static str> { +pub fn evaluate_kiro_pre_tool_use(event_json: &str) -> Option { let parsed: Value = serde_json::from_str(event_json).ok()?; let tool_name = parsed.get("tool_name").and_then(Value::as_str)?; if !is_kiro_delegation_tool(tool_name) { return None; } - if kiro_event_has_research_text(parsed.get("tool_input").unwrap_or(&Value::Null)) { - Some(TOKENSAVE_RESEARCH_BLOCK_REASON) + let tool_input = parsed.get("tool_input").unwrap_or(&Value::Null); + if kiro_event_has_research_text(tool_input) { + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Kiro, + session_id: event_session_id(&parsed), + tool_name: Some(tool_name.to_string()), + command: None, + prompt: kiro_event_text(tool_input), + subagent_type: Some(tool_name.to_string()), + file_path: None, + hints_enabled: true, + }); + Some(hint.map_or_else( + || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), + |hint| { + format!( + "{}\n\n{}", + TOKENSAVE_RESEARCH_BLOCK_REASON, + format_tool_hint(&hint) + ) + }, + )) } else { None } @@ -1054,12 +1139,16 @@ fn is_kiro_delegation_tool(tool_name: &str) -> bool { } fn kiro_event_has_research_text(value: &Value) -> bool { + kiro_event_text(value).is_some_and(|text| is_code_research_prompt(&text)) +} + +fn kiro_event_text(value: &Value) -> Option { let mut text = Vec::new(); collect_kiro_task_strings(value, &mut text); if text.is_empty() { collect_strings(value, &mut text); } - text.iter().any(|s| is_code_research_prompt(s)) + (!text.is_empty()).then(|| text.join("\n")) } fn collect_kiro_task_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { @@ -1186,6 +1275,72 @@ async fn sync_for_cursor_event(event_json: &str) -> crate::errors::Result<()> { } } +fn cursor_prompt_hint(event_json: &str) -> Option { + let parsed = serde_json::from_str::(event_json).ok()?; + decide_hint(&ToolHintInput { + agent: HintAgent::Cursor, + session_id: event_session_id(&parsed), + tool_name: None, + command: None, + prompt: prompt_like_text(&parsed), + subagent_type: None, + file_path: parsed + .get("file_path") + .and_then(Value::as_str) + .map(str::to_string), + hints_enabled: true, + }) +} + +fn codex_prompt_hint(event_json: &str) -> Option { + let parsed = serde_json::from_str::(event_json).ok()?; + decide_hint(&ToolHintInput { + agent: HintAgent::Codex, + session_id: event_session_id(&parsed), + tool_name: None, + command: None, + prompt: prompt_like_text(&parsed), + subagent_type: None, + file_path: None, + hints_enabled: true, + }) +} + +fn prompt_like_text(parsed: &Value) -> Option { + [ + "prompt", + "user_prompt", + "message", + "input", + "task", + "description", + ] + .iter() + .find_map(|key| parsed.get(*key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn event_session_id(parsed: &Value) -> Option { + ["session_id", "conversation_id", "chat_id"] + .iter() + .find_map(|key| parsed.get(*key).and_then(Value::as_str)) + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +fn format_tool_hint(hint: &ToolHint) -> String { + format!("tokensave hint: {}\n{}", hint.message, hint.context) +} + +fn append_tool_hint(context: &mut String, hint: &ToolHint) { + if !context.ends_with('\n') { + context.push('\n'); + } + context.push_str(&format_tool_hint(hint)); + context.push('\n'); +} + fn kiro_project_root(event_json: &str) -> Option { let cwd = event_cwd(event_json).or_else(|| std::env::current_dir().ok())?; crate::config::discover_project_root(&cwd) diff --git a/src/hooks/tool_hints.rs b/src/hooks/tool_hints.rs index ff74626755..84f7c4781b 100644 --- a/src/hooks/tool_hints.rs +++ b/src/hooks/tool_hints.rs @@ -9,8 +9,10 @@ use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HintAgent { + Claude, Cursor, Codex, + Kiro, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index bbc5eaf2fa..cac5303e29 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -136,6 +136,7 @@ fn test_block_response_has_reason() { let result = evaluate_hook_decision(input); let reason = get_block_reason(&result); assert!(reason.contains("tokensave MCP tools")); + assert!(reason.contains("tokensave hint:")); } #[test] @@ -167,6 +168,7 @@ fn test_kiro_blocks_delegate_code_research_task() { }"#; let reason = evaluate_kiro_pre_tool_use(input).unwrap(); assert!(reason.contains("tokensave MCP tools")); + assert!(reason.contains("tokensave hint:")); } #[test] @@ -226,6 +228,10 @@ fn test_cursor_subagent_start_blocks_explore_research_task() { .as_str() .unwrap_or_default() .contains("tokensave MCP tools")); + assert!(v["user_message"] + .as_str() + .unwrap_or_default() + .contains("tokensave hint:")); assert!( v.get("hookSpecificOutput").is_none(), "Cursor hook output must use Cursor's documented subagentStart fields" @@ -542,6 +548,10 @@ fn test_codex_subagent_start_redirects_explore_research_agent() { .as_str() .unwrap_or_default() .contains("tokensave MCP tools")); + assert!(v["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap_or_default() + .contains("tokensave hint:")); // Must use the Codex output schema, not Cursor's `permission`/`user_message`. assert!( v.get("permission").is_none(), From e65943598de5102119b7f76838bd7a88fcb8cd39 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 01:47:38 +0200 Subject: [PATCH 06/10] fix(install): harden local doctor and cursor paths --- src/agents/codex.rs | 9 ++++++++- src/agents/cursor.rs | 8 ++++++++ tests/agent_test.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 39bebd2cd7..326480e3f2 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -96,7 +96,7 @@ impl AgentIntegration for CodexIntegration { let local_codex_dir = ctx.project_path.join(".codex"); if local_codex_dir.join("config.toml").exists() || local_codex_dir.join("hooks.json").exists() - || ctx.project_path.join("AGENTS.md").exists() + || local_agents_md_has_tokensave(&ctx.project_path.join("AGENTS.md")) { doctor_check_config(dc, &local_codex_dir.join("config.toml")); doctor_check_prompt_file(dc, &ctx.project_path.join("AGENTS.md")); @@ -133,6 +133,13 @@ impl AgentIntegration for CodexIntegration { } } +fn local_agents_md_has_tokensave(path: &Path) -> bool { + path.exists() + && std::fs::read_to_string(path) + .unwrap_or_default() + .contains("## Prefer tokensave MCP tools") +} + // --------------------------------------------------------------------------- // Install helpers // --------------------------------------------------------------------------- diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 828d3613b9..4e7d425e26 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -48,6 +48,14 @@ impl AgentIntegration for CursorIntegration { fn install_local(&self, ctx: &InstallContext, project_path: &Path) -> Result<()> { let cursor_dir = project_path.join(".cursor"); + for path in [ + cursor_dir.join("mcp.json"), + cursor_dir.join("rules/tokensave.mdc"), + cursor_dir.join("permissions.json"), + cursor_dir.join("hooks.json"), + ] { + super::ensure_project_local_safe_path(project_path, &path)?; + } install_mcp_server( &cursor_dir.join("mcp.json"), &ctx.tokensave_bin, diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 4b65b57736..ea1d5bbbde 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -863,6 +863,28 @@ fn test_local_install_cursor_reconciles_existing_hooks_idempotently() { ); } +#[cfg(unix)] +#[test] +fn test_local_install_cursor_rejects_symlinked_cursor_dir() { + use std::os::unix::fs::symlink; + + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + symlink(outside.path(), project.path().join(".cursor")).unwrap(); + + let output = run_local_install("cursor", project.path(), home.path()); + assert!( + !output.status.success(), + "local Cursor install should reject symlinked .cursor directories" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("symlink"), + "error should explain the symlink refusal, got:\n{stderr}" + ); +} + #[test] fn test_local_install_supported_agents_write_project_paths() { let cases = [ @@ -2291,6 +2313,30 @@ fn test_healthcheck_codex_local_install_checks_project_config() { ); } +#[test] +fn test_healthcheck_codex_ignores_unrelated_project_agents_md() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + std::fs::write( + project.path().join("AGENTS.md"), + "Project-specific agent instructions without tokensave.\n", + ) + .unwrap(); + let ctx = make_install_ctx(home.path()); + CodexIntegration.install(&ctx).unwrap(); + + let mut dc = DoctorCounters::new(); + let hctx = HealthcheckContext { + home: home.path().to_path_buf(), + project_path: project.path().to_path_buf(), + }; + CodexIntegration.healthcheck(&mut dc, &hctx); + assert_eq!( + dc.issues, 0, + "global Codex healthcheck should be used when project AGENTS.md is unrelated" + ); +} + #[test] fn test_healthcheck_cursor_clean_install() { let dir = TempDir::new().unwrap(); From 2ee1b94fbefe6bc7bb02eed7afae0fee351c8cd7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 02:04:01 +0200 Subject: [PATCH 07/10] fix(install): finish local adapter hardening --- scripts/tokensave-dev-mcp.sh | 3 +- src/agents/codex.rs | 7 +++++ src/agents/cursor.rs | 29 +++++++++--------- src/hooks.rs | 59 ++++++++++++------------------------ tests/agent_test.rs | 6 ++-- 5 files changed, 46 insertions(+), 58 deletions(-) diff --git a/scripts/tokensave-dev-mcp.sh b/scripts/tokensave-dev-mcp.sh index b0f4905444..eaad6976cc 100755 --- a/scripts/tokensave-dev-mcp.sh +++ b/scripts/tokensave-dev-mcp.sh @@ -12,7 +12,6 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" MANIFEST="$REPO_ROOT/Cargo.toml" -DEFAULT_PROJECT_ROOT="$REPO_ROOT" -PROJECT_ROOT="${TOKENSAVE_DEV_PROJECT_ROOT:-$DEFAULT_PROJECT_ROOT}" +PROJECT_ROOT="${TOKENSAVE_DEV_PROJECT_ROOT:-$REPO_ROOT}" exec cargo run --quiet --manifest-path "$MANIFEST" -- serve --path "$PROJECT_ROOT" "$@" diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 326480e3f2..e50eef3eb5 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -61,6 +61,13 @@ impl AgentIntegration for CodexIntegration { fn install_local(&self, ctx: &InstallContext, project_path: &Path) -> Result<()> { let codex_dir = project_path.join(".codex"); + for path in [ + codex_dir.join("config.toml"), + codex_dir.join("hooks.json"), + project_path.join("AGENTS.md"), + ] { + super::ensure_project_local_safe_path(project_path, &path)?; + } std::fs::create_dir_all(&codex_dir).ok(); install_mcp_server( &codex_dir.join("config.toml"), diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 4e7d425e26..d47638654b 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -188,11 +188,9 @@ fn install_permissions(permissions_path: &Path) -> Result<()> { } }; - let tokensave_tools = tool_names(); - let known_tokensave_entries: std::collections::HashSet = tokensave_tools - .iter() - .map(|tool| format!("tokensave:{tool}")) - .collect(); + let tokensave_entries = cursor_permission_entries(); + let known_tokensave_entries: std::collections::HashSet = + tokensave_entries.iter().cloned().collect(); let existing = permissions["mcpAllowlist"] .as_array() .map(|arr| { @@ -205,8 +203,7 @@ fn install_permissions(permissions_path: &Path) -> Result<()> { }) .unwrap_or_default(); let mut allow = existing; - for tool in tokensave_tools { - let entry = format!("tokensave:{tool}"); + for entry in tokensave_entries { if !allow.iter().any(|existing| existing == &entry) { allow.push(entry); } @@ -221,6 +218,13 @@ fn install_permissions(permissions_path: &Path) -> Result<()> { Ok(()) } +fn cursor_permission_entries() -> Vec { + tool_names() + .into_iter() + .map(|tool| format!("tokensave:{tool}")) + .collect() +} + 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) { @@ -464,19 +468,16 @@ fn doctor_check_permissions(dc: &mut DoctorCounters, permissions_path: &Path) { .as_array() .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) .unwrap_or_default(); - let expected: Vec = tool_names() - .into_iter() - .map(|tool| format!("tokensave:{tool}")) - .collect(); + let expected = cursor_permission_entries(); + let expected_set: std::collections::HashSet<&str> = + expected.iter().map(String::as_str).collect(); let missing = expected .iter() .filter(|entry| !installed.contains(entry.as_str())) .count(); let stale = installed .iter() - .filter(|entry| { - entry.starts_with("tokensave:") && !expected.iter().any(|expected| expected == **entry) - }) + .filter(|entry| entry.starts_with("tokensave:") && !expected_set.contains(*entry)) .count(); if missing == 0 && stale == 0 { dc.pass(&format!( diff --git a/src/hooks.rs b/src/hooks.rs index 7e32bbfb62..6b0b275c62 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -22,6 +22,19 @@ code research. Tokensave is faster and more precise for symbol relationships, \ call paths, and code structure. Only use agents for code exploration if you \ have already tried tokensave and it cannot answer the question."; +fn research_block_reason(hint: Option) -> String { + hint.map_or_else( + || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), + |hint| { + format!( + "{}\n\n{}", + TOKENSAVE_RESEARCH_BLOCK_REASON, + format_tool_hint(&hint) + ) + }, + ) +} + /// `PreToolUse` hook handler for Claude Code's Agent tool matcher. /// /// Reads the `TOOL_INPUT` environment variable (JSON), inspects the @@ -56,16 +69,7 @@ pub fn evaluate_hook_decision(tool_input: &str) -> String { file_path: None, hints_enabled: true, }); - let block_reason = hint.map_or_else( - || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), - |hint| { - format!( - "{}\n\n{}", - TOKENSAVE_RESEARCH_BLOCK_REASON, - format_tool_hint(&hint) - ) - }, - ); + let block_reason = research_block_reason(hint); let block_msg = || { serde_json::json!({ "hookSpecificOutput": { @@ -254,10 +258,7 @@ pub fn evaluate_cursor_subagent_start(event_json: &str) -> Option { return Some( serde_json::json!({ "permission": "deny", - "user_message": hint - .map_or_else(|| TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), |hint| { - format!("{}\n\n{}", TOKENSAVE_RESEARCH_BLOCK_REASON, format_tool_hint(&hint)) - }) + "user_message": research_block_reason(hint) }) .to_string(), ); @@ -957,16 +958,7 @@ pub fn evaluate_codex_subagent_start(event_json: &str) -> Option { }); let is_explore = agent_type.eq_ignore_ascii_case("explore"); if is_explore || is_code_research_prompt(task) { - let context = hint.map_or_else( - || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), - |hint| { - format!( - "{}\n\n{}", - TOKENSAVE_RESEARCH_BLOCK_REASON, - format_tool_hint(&hint) - ) - }, - ); + let context = research_block_reason(hint); return Some(codex_additional_context_json("SubagentStart", &context)); } None @@ -1108,27 +1100,18 @@ pub fn evaluate_kiro_pre_tool_use(event_json: &str) -> Option { } let tool_input = parsed.get("tool_input").unwrap_or(&Value::Null); - if kiro_event_has_research_text(tool_input) { + if let Some(prompt) = kiro_event_text(tool_input).filter(|text| is_code_research_prompt(text)) { let hint = decide_hint(&ToolHintInput { agent: HintAgent::Kiro, session_id: event_session_id(&parsed), tool_name: Some(tool_name.to_string()), command: None, - prompt: kiro_event_text(tool_input), + prompt: Some(prompt), subagent_type: Some(tool_name.to_string()), file_path: None, hints_enabled: true, }); - Some(hint.map_or_else( - || TOKENSAVE_RESEARCH_BLOCK_REASON.to_string(), - |hint| { - format!( - "{}\n\n{}", - TOKENSAVE_RESEARCH_BLOCK_REASON, - format_tool_hint(&hint) - ) - }, - )) + Some(research_block_reason(hint)) } else { None } @@ -1138,10 +1121,6 @@ fn is_kiro_delegation_tool(tool_name: &str) -> bool { matches!(tool_name, "delegate" | "subagent" | "use_subagent") } -fn kiro_event_has_research_text(value: &Value) -> bool { - kiro_event_text(value).is_some_and(|text| is_code_research_prompt(&text)) -} - fn kiro_event_text(value: &Value) -> Option { let mut text = Vec::new(); collect_kiro_task_strings(value, &mut text); diff --git a/tests/agent_test.rs b/tests/agent_test.rs index ea1d5bbbde..51ece4fd22 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -1145,9 +1145,11 @@ fn test_codex_install_creates_config() { let section_start = content.find(§ion).unwrap_or_else(|| { panic!("Codex config should include auto-approval section {section}") }); - let after_section = &content[section_start..]; + let section_body = content[section_start..] + .split_once("\n[") + .map_or(&content[section_start..], |(body, _)| body); assert!( - after_section.contains("approval_mode = \"auto\""), + section_body.contains("approval_mode = \"auto\""), "Codex should auto-approve tokensave tool {tool}" ); } From 5db0532ad726d4704b340bb0211e4cf109eacf38 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 02:38:24 +0200 Subject: [PATCH 08/10] refactor(install): use InstallScope enum and shared hook quoting Replace the `(is_local_install, enable_global_db)` boolean pair in the cursor and codex `install_mcp_server` helpers with a shared `InstallScope` enum (`Global` / `ProjectLocal`) so the two invalid combinations are unrepresentable and each agent maps the scope via an exhaustive match. Generated cursor `mcp.json` and codex `config.toml` remain byte-identical. Wire cursor, codex, and kiro hook-command construction to the shared `super::hook_command` helper instead of POSIX-only local quoting. POSIX output for cursor/codex is unchanged; on Windows hook commands are now double-quoted with normalized separators, fixing a latent quoting bug. This also clears the four recurring unused-code warnings for `hook_command`, `hook_command_for_platform`, `quote_windows_command_arg`, and `quote_posix_command_arg`. --- src/agents/codex.rs | 30 ++++++++++-------------------- src/agents/cursor.rs | 33 ++++++++++++--------------------- src/agents/kiro.rs | 12 ++++-------- src/agents/mod.rs | 15 +++++++++++++++ 4 files changed, 41 insertions(+), 49 deletions(-) diff --git a/src/agents/codex.rs b/src/agents/codex.rs index e50eef3eb5..c0eeef6b46 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -21,6 +21,7 @@ use crate::errors::{Result, TokenSaveError}; use super::{ backup_config_file, load_json_file_strict, load_toml_file, safe_write_json_file, tool_names, write_toml_file, AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, + InstallScope, }; /// `OpenAI` Codex CLI agent. @@ -40,7 +41,7 @@ impl AgentIntegration for CodexIntegration { std::fs::create_dir_all(&codex_dir).ok(); let config_path = codex_dir.join("config.toml"); - install_mcp_server(&config_path, &ctx.tokensave_bin, false, true)?; + install_mcp_server(&config_path, &ctx.tokensave_bin, InstallScope::Global)?; let agents_md = codex_dir.join("AGENTS.md"); install_prompt_rules(&agents_md)?; @@ -72,8 +73,7 @@ impl AgentIntegration for CodexIntegration { install_mcp_server( &codex_dir.join("config.toml"), &ctx.tokensave_bin, - true, - false, + InstallScope::ProjectLocal, )?; install_prompt_rules(&project_path.join("AGENTS.md"))?; install_hooks(&codex_dir.join("hooks.json"), &ctx.tokensave_bin)?; @@ -152,12 +152,7 @@ fn local_agents_md_has_tokensave(path: &Path) -> bool { // --------------------------------------------------------------------------- /// Register MCP server and auto-approve tools in ~/.codex/config.toml. -fn install_mcp_server( - config_path: &Path, - tokensave_bin: &str, - is_local_install: bool, - enable_global_db: bool, -) -> Result<()> { +fn install_mcp_server(config_path: &Path, tokensave_bin: &str, scope: InstallScope) -> Result<()> { let mut config = load_toml_file(config_path)?; // Ensure [mcp_servers.tokensave] exists @@ -180,17 +175,16 @@ fn install_mcp_server( "command".to_string(), toml::Value::String(tokensave_bin.to_string()), ); - let args = if is_local_install { - vec![ + let args = match scope { + InstallScope::Global => vec![toml::Value::String("serve".to_string())], + InstallScope::ProjectLocal => vec![ toml::Value::String("serve".to_string()), toml::Value::String("--path".to_string()), toml::Value::String(".".to_string()), - ] - } else { - vec![toml::Value::String("serve".to_string())] + ], }; server_table.insert("args".to_string(), toml::Value::Array(args)); - if enable_global_db { + if scope == InstallScope::Global { let mut env_table = toml::map::Map::new(); env_table.insert( "TOKENSAVE_ENABLE_GLOBAL_DB".to_string(), @@ -352,7 +346,7 @@ fn install_codex_hook_event( let handler = json!({ "type": "command", - "command": format!("{} {subcommand}", shell_quote(tokensave_bin)), + "command": super::hook_command(tokensave_bin, subcommand), "timeout": timeout, }); let mut group = json!({ "hooks": [handler] }); @@ -375,10 +369,6 @@ fn group_has_subcommand(group: &serde_json::Value, subcommand: &str) -> bool { }) } -fn shell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - /// Codex requires non-managed command hooks to be trusted via `/hooks` before /// they run; newly installed/changed hooks are skipped until trusted. fn print_hook_trust_guidance() { diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index d47638654b..c9721268bf 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -12,7 +12,7 @@ use crate::errors::{Result, TokenSaveError}; use super::{ backup_and_write_json, backup_config_file, load_json_file, load_json_file_strict, load_jsonc_file_strict, safe_write_json_file, tool_names, AgentIntegration, DoctorCounters, - HealthcheckContext, InstallContext, + HealthcheckContext, InstallContext, InstallScope, }; /// Cursor agent. @@ -31,8 +31,7 @@ impl AgentIntegration for CursorIntegration { install_mcp_server( &ctx.home.join(".cursor/mcp.json"), &ctx.tokensave_bin, - false, - true, + InstallScope::Global, )?; eprintln!(); @@ -59,8 +58,7 @@ impl AgentIntegration for CursorIntegration { install_mcp_server( &cursor_dir.join("mcp.json"), &ctx.tokensave_bin, - true, - false, + InstallScope::ProjectLocal, )?; install_project_rule(&cursor_dir.join("rules/tokensave.mdc"))?; install_permissions(&cursor_dir.join("permissions.json"))?; @@ -115,12 +113,7 @@ impl AgentIntegration for CursorIntegration { // Uninstall helpers // --------------------------------------------------------------------------- -fn install_mcp_server( - mcp_path: &Path, - tokensave_bin: &str, - is_local_install: bool, - enable_global_db: bool, -) -> Result<()> { +fn install_mcp_server(mcp_path: &Path, tokensave_bin: &str, scope: InstallScope) -> Result<()> { if let Some(parent) = mcp_path.parent() { std::fs::create_dir_all(parent).ok(); } @@ -140,11 +133,13 @@ fn install_mcp_server( "command": tokensave_bin, "args": ["serve"] }); - if is_local_install { - server["args"] = json!(["serve", "--path", "."]); - } - if enable_global_db { - server["env"]["TOKENSAVE_ENABLE_GLOBAL_DB"] = json!("1"); + match scope { + InstallScope::Global => { + server["env"]["TOKENSAVE_ENABLE_GLOBAL_DB"] = json!("1"); + } + InstallScope::ProjectLocal => { + server["args"] = json!(["serve", "--path", "."]); + } } settings["mcpServers"]["tokensave"] = server; @@ -325,7 +320,7 @@ fn install_cursor_hook_entry( .collect(); let mut entry = json!({ - "command": format!("{} {subcommand}", shell_quote(tokensave_bin)), + "command": super::hook_command(tokensave_bin, subcommand), "timeout": timeout }); if let Some(matcher) = matcher { @@ -347,10 +342,6 @@ fn write_generated_text(path: &Path, contents: &str) -> Result<()> { }) } -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/agents/kiro.rs b/src/agents/kiro.rs index 043c8e41f4..c8ff9bc68d 100644 --- a/src/agents/kiro.rs +++ b/src/agents/kiro.rs @@ -183,10 +183,6 @@ fn mcp_server_entry(tokensave_bin: &str) -> serde_json::Value { }) } -fn hook_command(tokensave_bin: &str, subcommand: &str) -> String { - format!("{tokensave_bin} {subcommand}") -} - fn file_resource_uri(path: &Path) -> String { let path = path.to_string_lossy().replace('\\', "/"); let path = percent_encode_file_uri_path(&path); @@ -226,26 +222,26 @@ fn managed_agent_config(tokensave_bin: &str, steering_path: &Path) -> serde_json "hooks": { "userPromptSubmit": [ { - "command": hook_command(tokensave_bin, KIRO_PROMPT_HOOK), + "command": super::hook_command(tokensave_bin, KIRO_PROMPT_HOOK), "timeout_ms": KIRO_SHORT_HOOK_TIMEOUT_MS } ], "preToolUse": [ { "matcher": "delegate", - "command": hook_command(tokensave_bin, KIRO_PRE_TOOL_HOOK), + "command": super::hook_command(tokensave_bin, KIRO_PRE_TOOL_HOOK), "timeout_ms": KIRO_SHORT_HOOK_TIMEOUT_MS }, { "matcher": "subagent", - "command": hook_command(tokensave_bin, KIRO_PRE_TOOL_HOOK), + "command": super::hook_command(tokensave_bin, KIRO_PRE_TOOL_HOOK), "timeout_ms": KIRO_SHORT_HOOK_TIMEOUT_MS } ], "postToolUse": [ { "matcher": "fs_write", - "command": hook_command(tokensave_bin, KIRO_POST_TOOL_HOOK), + "command": super::hook_command(tokensave_bin, KIRO_POST_TOOL_HOOK), "timeout_ms": KIRO_SYNC_HOOK_TIMEOUT_MS } ] diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 8047e9db2f..6779bb2b41 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -122,6 +122,21 @@ pub struct HealthcheckContext { pub project_path: PathBuf, } +/// Where an MCP server registration is being written. +/// +/// Replaces the previous `(is_local_install, enable_global_db)` boolean pair +/// in the per-agent `install_mcp_server` helpers, which only ever took two of +/// the four combinations. Encoding the intent as an enum makes the two invalid +/// combinations unrepresentable and lets each agent map the scope to its own +/// args/env wiring via an exhaustive `match`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstallScope { + /// User-global install: `serve` with the global DB enabled. + Global, + /// Project-local install: `serve --path .` with no global DB. + ProjectLocal, +} + // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- From 3b041ceea0f803b4b13850fb6780f03e198d57ec Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 02:38:31 +0200 Subject: [PATCH 09/10] feat(install): re-enable selective silent agent reinstall `should_skip_agent_install_maintenance` was hardwired to always return true, leaving the `check_install_stale` gate and the silent-reinstall block unreachable. Re-enable them selectively: skip the implicit reinstall scan for `Serve` (the MCP hot path with a 30 s initialize timeout, #84), `Install` / `Reinstall` (already install), and `Tool` (per-invocation hot path), and run it for every other command so agent permissions, hooks, and MCP config re-sync after a binary upgrade. Update the startup test to assert the selective behavior (skip for serve/install/reinstall/tool; run for representative everyday commands). --- src/main.rs | 56 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 998823acef..54dd9cefee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1227,12 +1227,25 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { ) } -fn should_skip_agent_install_maintenance(_command: &Commands) -> bool { - // Never mutate user-profile agent configs as an implicit startup side - // effect. Global/profile installs remain available through explicit - // `tokensave install`, `tokensave reinstall`, and `tokensave uninstall` - // command handling below; project-local setup remains `install --local`. - true +fn should_skip_agent_install_maintenance(command: &Commands) -> bool { + // Selectively gate the implicit `check_install_stale` + silent-reinstall + // path so agent permissions/hooks/MCP config stay in sync after a binary + // upgrade, without firing on paths where it would be wrong or wasteful: + // - `Serve`: the MCP hot path with a 30 s client `initialize` timeout + // (#84). Reinstalling every tracked agent before the stdio loop starts + // can blow that budget, so it must stay off `serve`. + // - `Install` / `Reinstall`: already perform installation — don't + // double-install as an implicit prelude to the explicit command. + // - `Tool`: per-invocation tool calls are a hot-ish path; skip the + // reinstall scan there too. + // Every other command (the normal everyday invocations) runs maintenance. + matches!( + command, + Commands::Serve { .. } + | Commands::Install { .. } + | Commands::Reinstall + | Commands::Tool { .. } + ) } fn is_local_install_command(command: &Commands) -> bool { @@ -1277,14 +1290,13 @@ mod startup_tests { } #[test] - fn all_commands_skip_implicit_agent_install_maintenance() { - assert!(should_skip_agent_install_maintenance(&Commands::Tool { - name: Some("message_search".to_string()), - args: Vec::new(), - })); - assert!(should_skip_agent_install_maintenance(&Commands::Init { + fn agent_install_maintenance_is_selective() { + // Skip the implicit reinstall scan on the hot path (`serve`), on the + // explicit install commands (they already install), and on per-call + // tool invocations. + assert!(should_skip_agent_install_maintenance(&Commands::Serve { path: None, - skip_folders: Vec::new(), + timings: false, })); assert!(should_skip_agent_install_maintenance(&Commands::Install { agent: Some("cursor".to_string()), @@ -1292,6 +1304,24 @@ mod startup_tests { profile: None, })); assert!(should_skip_agent_install_maintenance(&Commands::Reinstall)); + assert!(should_skip_agent_install_maintenance(&Commands::Tool { + name: Some("message_search".to_string()), + args: Vec::new(), + })); + + // Run maintenance for normal everyday command invocations so a binary + // upgrade re-syncs agent config. + assert!(!should_skip_agent_install_maintenance(&Commands::Init { + path: None, + skip_folders: Vec::new(), + })); + assert!(!should_skip_agent_install_maintenance(&Commands::Status { + path: None, + json: false, + short: false, + details: false, + runtime: false, + })); } #[test] From 294a7fd67168619cc304b67570889bc08b67fdf2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 02:41:18 +0200 Subject: [PATCH 10/10] fix(install): exclude uninstall and doctor from implicit reinstall Add `Uninstall` and `Doctor` to `should_skip_agent_install_maintenance`'s skip set, restoring the original CHANGELOG #84 intent: don't mutate agent configs during the read-only `doctor` diagnostic, and don't reinstall configs right before `uninstall` removes them. Update the selective-gate test to assert skip for both alongside serve/install/reinstall/tool, while still running maintenance for representative everyday commands. --- src/main.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main.rs b/src/main.rs index 54dd9cefee..2e9a80016e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1236,6 +1236,10 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { // can blow that budget, so it must stay off `serve`. // - `Install` / `Reinstall`: already perform installation — don't // double-install as an implicit prelude to the explicit command. + // - `Uninstall`: about to remove agent configs — don't reinstall them + // first (per the original #84 intent). + // - `Doctor`: a read-only diagnostic — must not mutate agent configs as + // a side effect (per the original #84 intent). // - `Tool`: per-invocation tool calls are a hot-ish path; skip the // reinstall scan there too. // Every other command (the normal everyday invocations) runs maintenance. @@ -1244,6 +1248,8 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { Commands::Serve { .. } | Commands::Install { .. } | Commands::Reinstall + | Commands::Uninstall { .. } + | Commands::Doctor { .. } | Commands::Tool { .. } ) } @@ -1309,6 +1315,18 @@ mod startup_tests { args: Vec::new(), })); + // Also skip for uninstall (about to remove configs) and doctor (a + // read-only diagnostic) — restoring the original #84 intent. + assert!(should_skip_agent_install_maintenance( + &Commands::Uninstall { + agent: Some("cursor".to_string()), + profile: None, + } + )); + assert!(should_skip_agent_install_maintenance(&Commands::Doctor { + agent: Some("cursor".to_string()), + })); + // Run maintenance for normal everyday command invocations so a binary // upgrade re-syncs agent config. assert!(!should_skip_agent_install_maintenance(&Commands::Init {