handoff-mcp-server 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.13.0"
147
+ version = "0.13.1"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
@@ -393,6 +393,7 @@ version = "1.0.150"
393
393
  source = "registry+https://github.com/rust-lang/crates.io-index"
394
394
  checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
395
395
  dependencies = [
396
+ "indexmap",
396
397
  "itoa",
397
398
  "memchr",
398
399
  "serde",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.13.0"
3
+ version = "0.13.1"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
@@ -14,7 +14,7 @@ exclude = ["lefthook.yml", "tmp/", "wiki/", "docs/", ".claude/", ".vscode/"]
14
14
 
15
15
  [dependencies]
16
16
  serde = { version = "1", features = ["derive"] }
17
- serde_json = "1"
17
+ serde_json = { version = "1", features = ["preserve_order"] }
18
18
  toml = "0.8"
19
19
  toml_edit = "0.22"
20
20
  chrono = { version = "0.4", features = ["serde"] }
package/README.md CHANGED
@@ -33,12 +33,23 @@ At session start, the agent calls `handoff_load_context` to pick up where things
33
33
 
34
34
  ## Installation
35
35
 
36
- ### npm (recommended)
36
+ ### cargo (recommended if you have Rust)
37
+
38
+ ```bash
39
+ cargo install handoff-mcp
40
+ ```
41
+
42
+ ### npm
37
43
 
38
44
  ```bash
39
45
  npm install -g handoff-mcp-server
40
46
  ```
41
47
 
48
+ Both install the same binary. `cargo install` fetches the crate from
49
+ [crates.io](https://crates.io/crates/handoff-mcp) and compiles it directly;
50
+ `npm install` downloads the source and runs `cargo build --release` via
51
+ postinstall, so either way you need a Rust toolchain.
52
+
42
53
  ### Build from source
43
54
 
44
55
  ```bash
@@ -73,6 +84,34 @@ The `-s user` flag registers it globally (available in all projects). Verify wit
73
84
  }
74
85
  ```
75
86
 
87
+ ### Enable automatic memory injection (optional)
88
+
89
+ If you want project memories to be automatically injected into your context
90
+ (instead of calling `handoff_memory_query` manually every time), run:
91
+
92
+ ```bash
93
+ handoff-mcp setup
94
+ ```
95
+
96
+ This installs Claude Code hooks into `~/.claude/settings.json` that
97
+ automatically call `handoff_memory_query` on every prompt and file edit, and run
98
+ `handoff_memory_cleanup` at session start. Restart Claude Code after running setup.
99
+
100
+ You can check the current status or remove the hooks:
101
+
102
+ ```bash
103
+ handoff-mcp setup --check # Show hook status
104
+ handoff-mcp setup --uninstall # Remove handoff hooks
105
+ ```
106
+
107
+ The hooks fire on every prompt and file edit, which adds a small overhead per
108
+ interaction. If you want to stop automatic injection, run
109
+ `handoff-mcp setup --uninstall` — the memory tools themselves remain available
110
+ for manual use, only the automatic hooks are removed.
111
+
112
+ See [Automatic injection via hooks](#automatic-injection-via-hooks) for the
113
+ manual configuration alternative.
114
+
76
115
  ## Quick Start
77
116
 
78
117
  1. **Initialize** a project:
@@ -163,10 +202,10 @@ rename) so a concurrent reader never sees a partially-written file.
163
202
 
164
203
  | Tool | Purpose |
165
204
  |------|---------|
166
- | `memory_save` | Save a durable project memory (lesson/rule/convention/gotcha); detects exact and near-duplicate memories and hands near-duplicates back for AI-driven merge |
167
- | `memory_query` | Return the memories most relevant to the current prompt/file (BM25 + scope-path boost); with a `session_id`, suppresses repeats already injected this session |
168
- | `memory_delete` | Delete a memory by id (full id or unique prefix) |
169
- | `memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
205
+ | `handoff_memory_save` | Save a durable project memory (lesson/rule/convention/gotcha); detects exact and near-duplicate memories and hands near-duplicates back for AI-driven merge |
206
+ | `handoff_memory_query` | Return the memories most relevant to the current prompt/file (BM25 + scope-path boost); with a `session_id`, suppresses repeats already injected this session |
207
+ | `handoff_memory_delete` | Delete a memory by id (full id or unique prefix) |
208
+ | `handoff_memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
170
209
 
171
210
  See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
172
211
 
@@ -300,6 +339,11 @@ Sessions answer *"what was I doing last time?"*. **Memory** answers a longer-liv
300
339
  question: *"what should every session in this project always know?"* — durable
301
340
  lessons, rules, conventions, and gotchas that outlive any one session.
302
341
 
342
+ > **Note:** The memory tools (`handoff_memory_save`, `handoff_memory_query`, etc.) can always be
343
+ > called directly by the agent. For **automatic** injection — where relevant
344
+ > memories are surfaced on every prompt without the agent asking — you need to
345
+ > configure Claude Code hooks. See [Automatic injection via hooks](#automatic-injection-via-hooks).
346
+
303
347
  Memories live in `.handoff/memory/` (one JSON file per memory, plus per-session
304
348
  `injected/` sidecars). A built-in multilingual similarity engine (Japanese /
305
349
  English, dictionary-free) ranks relevance and detects duplicates, all in-memory
@@ -309,28 +353,28 @@ and sub-millisecond.
309
353
 
310
354
  The agent can call the memory tools at any time:
311
355
 
312
- - `memory_save` — record a memory. An exact duplicate is reported (not
356
+ - `handoff_memory_save` — record a memory. An exact duplicate is reported (not
313
357
  rewritten); a near-duplicate comes back as a `conflict` with both bodies so the
314
358
  agent can merge them (`merge_into=<id>`, `absorb_ids=[…]`) or save separately
315
359
  with `force=true`. **handoff-mcp never merges for you** — it surfaces both
316
360
  bodies and lets the agent decide.
317
- - `memory_query` — fetch the memories most relevant to some text and/or files.
318
- - `memory_delete` / `memory_cleanup` — prune and de-duplicate the store.
361
+ - `handoff_memory_query` — fetch the memories most relevant to some text and/or files.
362
+ - `handoff_memory_delete` / `handoff_memory_cleanup` — prune and de-duplicate the store.
319
363
 
320
364
  ### Automatic injection via hooks
321
365
 
322
366
  MCP is request/response — the server cannot push a memory into the agent's
323
367
  context on its own. **Claude Code hooks** close that gap: they fire regardless of
324
- what the agent intends, call `memory_query`, and inject the matching memories as
368
+ what the agent intends, call `handoff_memory_query`, and inject the matching memories as
325
369
  `additionalContext`. A per-session diff (keyed on the hook `session_id`) ensures
326
370
  the same memory is **not injected twice in one session** — and an *edited* memory
327
371
  (new content hash) is re-injected.
328
372
 
329
373
  | Event | Calls | Effect |
330
374
  |-------|-------|--------|
331
- | `UserPromptSubmit` | `memory_query` (prompt text) | Inject memories relevant to the prompt |
332
- | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `memory_query` (file path) | Inject memories scoped to the file being edited |
333
- | `SessionStart` | `memory_cleanup` | Merge exact duplicates, gc old sidecars |
375
+ | `UserPromptSubmit` | `handoff_memory_query` (prompt text) | Inject memories relevant to the prompt |
376
+ | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `handoff_memory_query` (file path) | Inject memories scoped to the file being edited |
377
+ | `SessionStart` | `handoff_memory_cleanup` | Merge exact duplicates, gc old sidecars |
334
378
 
335
379
  > **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
336
380
  > personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
@@ -347,14 +391,14 @@ MCP tool from a hook directly, with no wrapper script. In
347
391
  "hooks": {
348
392
  "UserPromptSubmit": [
349
393
  { "hooks": [ {
350
- "type": "mcp_tool", "server": "handoff", "tool": "memory_query",
394
+ "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query",
351
395
  "input": { "project_dir": "${cwd}",
352
396
  "session_id": "${session_id}", "text": "${prompt}" }
353
397
  } ] }
354
398
  ],
355
399
  "PreToolUse": [
356
400
  { "matcher": "Edit|Write|MultiEdit", "hooks": [ {
357
- "type": "mcp_tool", "server": "handoff", "tool": "memory_query",
401
+ "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query",
358
402
  "input": { "project_dir": "${cwd}",
359
403
  "session_id": "${session_id}", "tool_name": "${tool_name}",
360
404
  "text": "${tool_input.file_path}",
@@ -363,7 +407,7 @@ MCP tool from a hook directly, with no wrapper script. In
363
407
  ],
364
408
  "SessionStart": [
365
409
  { "hooks": [ {
366
- "type": "mcp_tool", "server": "handoff", "tool": "memory_cleanup",
410
+ "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup",
367
411
  "input": { "project_dir": "${cwd}" }
368
412
  } ] }
369
413
  ]
@@ -413,7 +457,7 @@ All under `[settings]` in `.handoff/config.toml`, all with safe defaults
413
457
  |-----|---------|---------|
414
458
  | `memory_enabled` | `true` | Master switch. When `false`, all four memory tools return a benign empty result and write nothing |
415
459
  | `memory_dup_threshold` | `0.72` | Jaccard similarity at/above which a save is a near-duplicate conflict and cleanup groups a cluster |
416
- | `memory_query_min_score` | `0.5` | BM25 relevance floor for `memory_query` results |
460
+ | `memory_query_min_score` | `0.5` | BM25 relevance floor for `handoff_memory_query` results |
417
461
  | `memory_query_limit` | `5` | Max memories returned per query |
418
462
  | `memory_stale_days` | `60` | Days without a reference before a memory is flagged stale |
419
463
  | `memory_injected_gc_days` | `14` | Age at which per-session injection sidecars are garbage-collected |
@@ -445,9 +489,9 @@ This project uses handoff-mcp for session continuity.
445
489
  - **Decisions**: Record decisions with confidence levels as they are made,
446
490
  not just at session end. Use `confirmed` for verified facts, `estimated`
447
491
  for reasonable assumptions, `unverified` for unknowns.
448
- - **Project memory**: Use `memory_save` to record durable lessons, rules,
492
+ - **Project memory**: Use `handoff_memory_save` to record durable lessons, rules,
449
493
  conventions, and gotchas that every future session should know. Use
450
- `memory_query` to retrieve relevant memories. Near-duplicate memories are
494
+ `handoff_memory_query` to retrieve relevant memories. Near-duplicate memories are
451
495
  surfaced as conflicts for you to merge or force-save — never merged silently.
452
496
  ```
453
497
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -2,15 +2,15 @@
2
2
  """Claude Code hook wrapper for handoff-mcp project memory.
3
3
 
4
4
  This is the **fallback path** for memory auto-injection. The preferred wiring is
5
- a native ``mcp_tool`` hook that calls ``memory_query`` / ``memory_cleanup``
5
+ a native ``mcp_tool`` hook that calls ``handoff_memory_query`` / ``handoff_memory_cleanup``
6
6
  directly (see README "Project Memory"); this script exists for Claude Code
7
7
  versions that lack the ``mcp_tool`` hook type, by translating a Claude Code hook
8
8
  event into a single JSON-RPC ``tools/call`` against the handoff-mcp server and
9
9
  emitting the result as ``hookSpecificOutput.additionalContext``.
10
10
 
11
11
  It speaks the server's line-delimited JSON-RPC over stdio: one request object on
12
- one line in, one response object on one line out. ``memory_query`` /
13
- ``memory_cleanup`` return their payload as a JSON *string* inside
12
+ one line in, one response object on one line out. ``handoff_memory_query`` /
13
+ ``handoff_memory_cleanup`` return their payload as a JSON *string* inside
14
14
  ``result.content[0].text`` (so both this wrapper and the native hook path parse
15
15
  it identically), which this script re-parses.
16
16
 
@@ -142,7 +142,7 @@ def _emit(event: str, context: str) -> None:
142
142
 
143
143
 
144
144
  def _format_memories(payload: dict) -> str:
145
- """Turn a ``memory_query`` payload into an injectable context block."""
145
+ """Turn a ``handoff_memory_query`` payload into an injectable context block."""
146
146
  memories = payload.get("memories") or []
147
147
  if not memories:
148
148
  return ""
@@ -188,7 +188,7 @@ def main() -> int:
188
188
  if file_paths:
189
189
  arguments["file_paths"] = file_paths
190
190
 
191
- payload = _call(bin_path, "memory_query", arguments)
191
+ payload = _call(bin_path, "handoff_memory_query", arguments)
192
192
  if payload:
193
193
  _emit(event, _format_memories(payload))
194
194
  return 0
@@ -197,7 +197,7 @@ def main() -> int:
197
197
  # Housekeeping only: merge exact duplicates and gc sidecars. We do not
198
198
  # inject the cleanup recommendations as context (that is for an explicit
199
199
  # AI-driven pass), so there is no additionalContext to emit here.
200
- _call(bin_path, "memory_cleanup", {"project_dir": project_dir})
200
+ _call(bin_path, "handoff_memory_cleanup", {"project_dir": project_dir})
201
201
  return 0
202
202
 
203
203
  # Unknown event — nothing to do.
package/src/lib.rs CHANGED
@@ -1,2 +1,3 @@
1
1
  pub mod mcp;
2
+ pub mod setup;
2
3
  pub mod storage;
package/src/main.rs CHANGED
@@ -3,6 +3,35 @@ use std::io::{self, BufRead, Write};
3
3
  use handoff_mcp::mcp::protocol::process_line;
4
4
 
5
5
  fn main() {
6
+ let args: Vec<String> = std::env::args().collect();
7
+
8
+ if args.len() >= 2 {
9
+ match args[1].as_str() {
10
+ "setup" => {
11
+ let check = args.iter().any(|a| a == "--check");
12
+ let uninstall = args.iter().any(|a| a == "--uninstall");
13
+ if let Err(e) = handoff_mcp::setup::run_setup(check, uninstall) {
14
+ eprintln!("Error: {e:#}");
15
+ std::process::exit(1);
16
+ }
17
+ return;
18
+ }
19
+ "--version" | "-V" => {
20
+ println!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
21
+ return;
22
+ }
23
+ "--help" | "-h" => {
24
+ print_help();
25
+ return;
26
+ }
27
+ other => {
28
+ eprintln!("Unknown command: {other}");
29
+ eprintln!("Run `handoff-mcp --help` for usage.");
30
+ std::process::exit(1);
31
+ }
32
+ }
33
+ }
34
+
6
35
  eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
7
36
 
8
37
  let stdin = io::stdin();
@@ -27,3 +56,21 @@ fn main() {
27
56
  }
28
57
  }
29
58
  }
59
+
60
+ fn print_help() {
61
+ println!(
62
+ "handoff-mcp v{version}
63
+ MCP server for AI session handoff
64
+
65
+ USAGE:
66
+ handoff-mcp Start the MCP server (stdio transport)
67
+ handoff-mcp setup Install memory auto-injection hooks into Claude Code
68
+ handoff-mcp setup --check Check if hooks are installed
69
+ handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
70
+
71
+ OPTIONS:
72
+ -h, --help Print this help message
73
+ -V, --version Print version",
74
+ version = env!("CARGO_PKG_VERSION")
75
+ );
76
+ }
@@ -75,10 +75,10 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
75
75
  "handoff_update_calendar" => calendar::handle_update_calendar(arguments),
76
76
  "handoff_update_labels" => calendar::handle_update_labels(arguments),
77
77
  "handoff_start_project" => calendar::handle_start_project(arguments),
78
- "memory_save" => memory::handle_save(arguments),
79
- "memory_query" => memory::handle_query(arguments),
80
- "memory_delete" => memory::handle_delete(arguments),
81
- "memory_cleanup" => memory::handle_cleanup(arguments),
78
+ "handoff_memory_save" => memory::handle_save(arguments),
79
+ "handoff_memory_query" => memory::handle_query(arguments),
80
+ "handoff_memory_delete" => memory::handle_delete(arguments),
81
+ "handoff_memory_cleanup" => memory::handle_cleanup(arguments),
82
82
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
83
83
  };
84
84
 
package/src/mcp/tools.rs CHANGED
@@ -1024,7 +1024,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1024
1024
  }),
1025
1025
  },
1026
1026
  ToolDefinition {
1027
- name: "memory_save".to_string(),
1027
+ name: "handoff_memory_save".to_string(),
1028
1028
  description: "Save a long-lived project memory (lesson/rule/convention/gotcha) that future sessions should respect. Detects exact and near-duplicate memories: an exact match is reported (not rewritten), a near-duplicate is returned as a 'conflict' with both bodies for you to merge (call again with merge_into=<id> and absorb_ids=[…]) or save separately with force=true. Returns a JSON string.".to_string(),
1029
1029
  input_schema: json!({
1030
1030
  "type": "object",
@@ -1042,7 +1042,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1042
1042
  }),
1043
1043
  },
1044
1044
  ToolDefinition {
1045
- name: "memory_query".to_string(),
1045
+ name: "handoff_memory_query".to_string(),
1046
1046
  description: "Return the project memories most relevant to the given text/file (BM25 + scope-path boosting). Intended for automatic injection via hooks, but callable directly. Returns a JSON string {\"memories\":[{id,text,kind,score}],\"injected_count\"}.".to_string(),
1047
1047
  input_schema: json!({
1048
1048
  "type": "object",
@@ -1059,7 +1059,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1059
1059
  }),
1060
1060
  },
1061
1061
  ToolDefinition {
1062
- name: "memory_delete".to_string(),
1062
+ name: "handoff_memory_delete".to_string(),
1063
1063
  description: "Delete a project memory by id (full id or unique prefix). Use for AI-driven cleanup of stale memories. Returns a JSON string {\"status\":\"deleted\",\"id\"}.".to_string(),
1064
1064
  input_schema: json!({
1065
1065
  "type": "object",
@@ -1071,7 +1071,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1071
1071
  }),
1072
1072
  },
1073
1073
  ToolDefinition {
1074
- name: "memory_cleanup".to_string(),
1074
+ name: "handoff_memory_cleanup".to_string(),
1075
1075
  description: "Housekeep the project memory store (intended for SessionStart). Silently merges exact duplicates (lossless), then returns recommendations the AI should act on: near-duplicate clusters (merge with memory_save merge_into=…) and stale memories (consider memory_delete). Also garbage-collects old per-session injection sidecars. Returns a JSON string {\"auto_merged_exact\":n,\"cleanup_recommendations\":{\"similar_clusters\":[…],\"stale\":[…]},\"injected_sidecars_removed\":k}.".to_string(),
1076
1076
  input_schema: json!({
1077
1077
  "type": "object",
package/src/setup.rs ADDED
@@ -0,0 +1,424 @@
1
+ use std::collections::BTreeMap;
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use anyhow::{Context, Result};
5
+ use serde_json::Value;
6
+
7
+ const HOOK_SERVER: &str = "handoff";
8
+ const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
9
+ const HOOK_TOOL_CLEANUP: &str = "handoff_memory_cleanup";
10
+
11
+ fn settings_path() -> Result<PathBuf> {
12
+ let home = std::env::var("HOME")
13
+ .or_else(|_| std::env::var("USERPROFILE"))
14
+ .context("cannot determine home directory (HOME / USERPROFILE not set)")?;
15
+ Ok(Path::new(&home).join(".claude").join("settings.json"))
16
+ }
17
+
18
+ fn read_settings(path: &Path) -> Result<Value> {
19
+ if path.exists() {
20
+ let text = std::fs::read_to_string(path)
21
+ .with_context(|| format!("failed to read {}", path.display()))?;
22
+ serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
23
+ } else {
24
+ Ok(Value::Object(serde_json::Map::new()))
25
+ }
26
+ }
27
+
28
+ fn write_settings(path: &Path, value: &Value) -> Result<()> {
29
+ let parent = path
30
+ .parent()
31
+ .context("settings path has no parent directory")?;
32
+ std::fs::create_dir_all(parent)
33
+ .with_context(|| format!("failed to create {}", parent.display()))?;
34
+
35
+ let text = serde_json::to_string_pretty(value)?;
36
+ let tmp = parent.join(".settings.json.tmp");
37
+ std::fs::write(&tmp, text + "\n")
38
+ .with_context(|| format!("failed to write {}", tmp.display()))?;
39
+ std::fs::rename(&tmp, path)
40
+ .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
41
+ }
42
+
43
+ fn mcp_tool_hook(tool: &str, input: Value) -> Value {
44
+ serde_json::json!({
45
+ "type": "mcp_tool",
46
+ "server": HOOK_SERVER,
47
+ "tool": tool,
48
+ "input": input
49
+ })
50
+ }
51
+
52
+ fn build_hooks_config() -> BTreeMap<&'static str, Value> {
53
+ let mut hooks = BTreeMap::new();
54
+
55
+ hooks.insert(
56
+ "UserPromptSubmit",
57
+ serde_json::json!([{
58
+ "hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
59
+ "project_dir": "${cwd}",
60
+ "session_id": "${session_id}",
61
+ "text": "${prompt}"
62
+ }))]
63
+ }]),
64
+ );
65
+
66
+ hooks.insert(
67
+ "PreToolUse",
68
+ serde_json::json!([{
69
+ "matcher": "Edit|Write|MultiEdit",
70
+ "hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
71
+ "project_dir": "${cwd}",
72
+ "session_id": "${session_id}",
73
+ "tool_name": "${tool_name}",
74
+ "text": "${tool_input.file_path}",
75
+ "file_paths": ["${tool_input.file_path}"]
76
+ }))]
77
+ }]),
78
+ );
79
+
80
+ hooks.insert(
81
+ "SessionStart",
82
+ serde_json::json!([{
83
+ "hooks": [mcp_tool_hook(HOOK_TOOL_CLEANUP, serde_json::json!({
84
+ "project_dir": "${cwd}"
85
+ }))]
86
+ }]),
87
+ );
88
+
89
+ hooks
90
+ }
91
+
92
+ fn has_handoff_hook(arr: &Value) -> bool {
93
+ let Some(entries) = arr.as_array() else {
94
+ return false;
95
+ };
96
+ for entry in entries {
97
+ let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
98
+ continue;
99
+ };
100
+ for hook in hooks {
101
+ if hook.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER) {
102
+ return true;
103
+ }
104
+ }
105
+ }
106
+ false
107
+ }
108
+
109
+ pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
110
+ anyhow::ensure!(
111
+ !(check_only && uninstall),
112
+ "--check and --uninstall cannot be used together"
113
+ );
114
+
115
+ let path = settings_path()?;
116
+ let mut settings = read_settings(&path)?;
117
+
118
+ if check_only {
119
+ return run_check(&settings, &path);
120
+ }
121
+
122
+ if uninstall {
123
+ return run_uninstall(&mut settings, &path);
124
+ }
125
+
126
+ run_install(&mut settings, &path)
127
+ }
128
+
129
+ fn run_check(settings: &Value, path: &Path) -> Result<()> {
130
+ println!("Settings file: {}", path.display());
131
+
132
+ let hooks_obj = settings.get("hooks");
133
+ let desired = build_hooks_config();
134
+ let mut all_ok = true;
135
+
136
+ for event in desired.keys() {
137
+ let installed = hooks_obj
138
+ .and_then(|h| h.get(*event))
139
+ .map(has_handoff_hook)
140
+ .unwrap_or(false);
141
+
142
+ if installed {
143
+ println!(" {event}: installed");
144
+ } else {
145
+ println!(" {event}: NOT installed");
146
+ all_ok = false;
147
+ }
148
+ }
149
+
150
+ if all_ok {
151
+ println!("\nAll hooks are configured. Memory auto-injection is active.");
152
+ } else {
153
+ println!("\nSome hooks are missing. Run `handoff-mcp setup` to install them.");
154
+ }
155
+
156
+ Ok(())
157
+ }
158
+
159
+ fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
160
+ let obj = settings
161
+ .as_object_mut()
162
+ .context("settings.json root is not an object")?;
163
+
164
+ let hooks_val = obj
165
+ .entry("hooks")
166
+ .or_insert_with(|| Value::Object(serde_json::Map::new()));
167
+ let hooks_obj = hooks_val
168
+ .as_object_mut()
169
+ .context("settings.json 'hooks' is not an object")?;
170
+
171
+ let desired = build_hooks_config();
172
+ let mut installed = 0u32;
173
+ let mut skipped = 0u32;
174
+
175
+ for (event, config) in desired {
176
+ let existing = hooks_obj.get(event);
177
+ if existing.map(has_handoff_hook).unwrap_or(false) {
178
+ println!(" {event}: already installed, skipping");
179
+ skipped += 1;
180
+ continue;
181
+ }
182
+
183
+ match existing {
184
+ Some(Value::Array(arr)) => {
185
+ let mut merged = arr.clone();
186
+ if let Some(new_entries) = config.as_array() {
187
+ merged.extend(new_entries.iter().cloned());
188
+ }
189
+ hooks_obj.insert(event.to_string(), Value::Array(merged));
190
+ println!(" {event}: merged with existing hooks");
191
+ }
192
+ _ => {
193
+ hooks_obj.insert(event.to_string(), config);
194
+ println!(" {event}: installed");
195
+ }
196
+ }
197
+ installed += 1;
198
+ }
199
+
200
+ if installed > 0 {
201
+ write_settings(path, settings)?;
202
+ println!("\nWrote {path}", path = path.display());
203
+ println!("{installed} hook(s) installed, {skipped} already present.");
204
+ println!("\nRestart Claude Code for hooks to take effect.");
205
+ } else {
206
+ println!("\nAll hooks already installed. Nothing to do.");
207
+ }
208
+
209
+ Ok(())
210
+ }
211
+
212
+ fn run_uninstall(settings: &mut Value, path: &Path) -> Result<()> {
213
+ let Some(hooks_obj) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
214
+ println!("No hooks configured. Nothing to remove.");
215
+ return Ok(());
216
+ };
217
+
218
+ let events: Vec<String> = hooks_obj.keys().cloned().collect();
219
+ let mut removed = 0u32;
220
+
221
+ for event in &events {
222
+ let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
223
+ continue;
224
+ };
225
+
226
+ let before = arr.len();
227
+ arr.retain(|entry| {
228
+ let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
229
+ return true;
230
+ };
231
+ !hooks
232
+ .iter()
233
+ .any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
234
+ });
235
+
236
+ let after = arr.len();
237
+ if before != after {
238
+ println!(" {event}: removed handoff hook(s)");
239
+ removed += 1;
240
+ }
241
+
242
+ if arr.is_empty() {
243
+ hooks_obj.remove(event);
244
+ }
245
+ }
246
+
247
+ if hooks_obj.is_empty() {
248
+ if let Some(obj) = settings.as_object_mut() {
249
+ obj.remove("hooks");
250
+ }
251
+ }
252
+
253
+ if removed > 0 {
254
+ write_settings(path, settings)?;
255
+ println!("\nWrote {path}", path = path.display());
256
+ println!("{removed} hook event(s) cleaned up.");
257
+ println!("\nRestart Claude Code for changes to take effect.");
258
+ } else {
259
+ println!("No handoff hooks found. Nothing to remove.");
260
+ }
261
+
262
+ Ok(())
263
+ }
264
+
265
+ #[cfg(test)]
266
+ mod tests {
267
+ use super::*;
268
+
269
+ #[test]
270
+ fn build_hooks_has_three_events() {
271
+ let hooks = build_hooks_config();
272
+ assert_eq!(hooks.len(), 3);
273
+ assert!(hooks.contains_key("UserPromptSubmit"));
274
+ assert!(hooks.contains_key("PreToolUse"));
275
+ assert!(hooks.contains_key("SessionStart"));
276
+ }
277
+
278
+ #[test]
279
+ fn has_handoff_hook_detects_presence() {
280
+ let arr = serde_json::json!([{
281
+ "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
282
+ }]);
283
+ assert!(has_handoff_hook(&arr));
284
+ }
285
+
286
+ #[test]
287
+ fn has_handoff_hook_returns_false_for_other_server() {
288
+ let arr = serde_json::json!([{
289
+ "hooks": [{"type": "mcp_tool", "server": "other", "tool": "other_tool"}]
290
+ }]);
291
+ assert!(!has_handoff_hook(&arr));
292
+ }
293
+
294
+ #[test]
295
+ fn has_handoff_hook_returns_false_for_empty() {
296
+ assert!(!has_handoff_hook(&serde_json::json!([])));
297
+ }
298
+
299
+ #[test]
300
+ fn install_into_empty_settings() {
301
+ let dir = tempfile::tempdir().unwrap();
302
+ let path = dir.path().join("settings.json");
303
+
304
+ let mut settings = Value::Object(serde_json::Map::new());
305
+ run_install(&mut settings, &path).unwrap();
306
+
307
+ let written: Value =
308
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
309
+ let hooks = written.get("hooks").unwrap().as_object().unwrap();
310
+ assert!(hooks.contains_key("UserPromptSubmit"));
311
+ assert!(hooks.contains_key("PreToolUse"));
312
+ assert!(hooks.contains_key("SessionStart"));
313
+ }
314
+
315
+ #[test]
316
+ fn install_merges_with_existing_hooks() {
317
+ let dir = tempfile::tempdir().unwrap();
318
+ let path = dir.path().join("settings.json");
319
+
320
+ let mut settings = serde_json::json!({
321
+ "hooks": {
322
+ "UserPromptSubmit": [{
323
+ "hooks": [{"type": "command", "command": "my-other-hook"}]
324
+ }]
325
+ }
326
+ });
327
+
328
+ run_install(&mut settings, &path).unwrap();
329
+
330
+ let written: Value =
331
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
332
+ let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
333
+ assert_eq!(user_prompt.len(), 2);
334
+ assert!(has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
335
+ }
336
+
337
+ #[test]
338
+ fn install_skips_if_already_present() {
339
+ let dir = tempfile::tempdir().unwrap();
340
+ let path = dir.path().join("settings.json");
341
+
342
+ let mut settings = Value::Object(serde_json::Map::new());
343
+ run_install(&mut settings, &path).unwrap();
344
+
345
+ let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
346
+ run_install(&mut settings2, &path).unwrap();
347
+
348
+ let written: Value =
349
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
350
+ let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
351
+ assert_eq!(user_prompt.len(), 1);
352
+ }
353
+
354
+ #[test]
355
+ fn uninstall_removes_handoff_hooks() {
356
+ let dir = tempfile::tempdir().unwrap();
357
+ let path = dir.path().join("settings.json");
358
+
359
+ let mut settings = Value::Object(serde_json::Map::new());
360
+ run_install(&mut settings, &path).unwrap();
361
+
362
+ let mut settings2: Value =
363
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
364
+ run_uninstall(&mut settings2, &path).unwrap();
365
+
366
+ let written: Value =
367
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
368
+ assert!(written.get("hooks").is_none());
369
+ }
370
+
371
+ #[test]
372
+ fn check_and_uninstall_conflict_is_rejected() {
373
+ assert!(run_setup(true, true).is_err());
374
+ }
375
+
376
+ #[test]
377
+ fn install_preserves_key_order() {
378
+ let dir = tempfile::tempdir().unwrap();
379
+ let path = dir.path().join("settings.json");
380
+
381
+ let original = r#"{
382
+ "env": {"A": "1", "B": "2"},
383
+ "model": "opus",
384
+ "permissions": {"defaultMode": "auto"}
385
+ }
386
+ "#;
387
+ std::fs::write(&path, original).unwrap();
388
+
389
+ let mut settings: Value = serde_json::from_str(original).unwrap();
390
+ run_install(&mut settings, &path).unwrap();
391
+
392
+ let written = std::fs::read_to_string(&path).unwrap();
393
+ let env_pos = written.find("\"env\"").unwrap();
394
+ let hooks_pos = written.find("\"hooks\"").unwrap();
395
+ let model_pos = written.find("\"model\"").unwrap();
396
+ assert!(env_pos < model_pos, "env should come before model");
397
+ assert!(
398
+ hooks_pos > env_pos,
399
+ "hooks should be appended after existing keys"
400
+ );
401
+ }
402
+
403
+ #[test]
404
+ fn uninstall_preserves_other_hooks() {
405
+ let dir = tempfile::tempdir().unwrap();
406
+ let path = dir.path().join("settings.json");
407
+
408
+ let mut settings = serde_json::json!({
409
+ "hooks": {
410
+ "UserPromptSubmit": [
411
+ {"hooks": [{"type": "command", "command": "my-hook"}]},
412
+ {"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]}
413
+ ]
414
+ }
415
+ });
416
+ run_uninstall(&mut settings, &path).unwrap();
417
+
418
+ let written: Value =
419
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
420
+ let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
421
+ assert_eq!(user_prompt.len(), 1);
422
+ assert!(!has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
423
+ }
424
+ }