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 +2 -1
- package/Cargo.toml +2 -2
- package/README.md +62 -18
- package/package.json +1 -1
- package/scripts/handoff-memory-hook.py +6 -6
- package/src/lib.rs +1 -0
- package/src/main.rs +47 -0
- package/src/mcp/handlers/mod.rs +4 -4
- package/src/mcp/tools.rs +4 -4
- package/src/setup.rs +424 -0
package/Cargo.lock
CHANGED
|
@@ -144,7 +144,7 @@ dependencies = [
|
|
|
144
144
|
|
|
145
145
|
[[package]]
|
|
146
146
|
name = "handoff-mcp"
|
|
147
|
-
version = "0.13.
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
318
|
-
- `
|
|
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 `
|
|
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` | `
|
|
332
|
-
| `PreToolUse` (`Edit\|Write\|MultiEdit`) | `
|
|
333
|
-
| `SessionStart` | `
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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 `
|
|
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 `
|
|
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
|
-
`
|
|
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.
|
|
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 ``
|
|
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. ``
|
|
13
|
-
``
|
|
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 ``
|
|
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, "
|
|
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, "
|
|
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
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
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -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
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
+
}
|