handoff-mcp-server 0.24.3 → 0.24.5
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 +7 -7
- package/Cargo.toml +1 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/main.rs +11 -7
- package/src/setup.rs +514 -69
package/Cargo.lock
CHANGED
|
@@ -25,9 +25,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
|
25
25
|
|
|
26
26
|
[[package]]
|
|
27
27
|
name = "bitflags"
|
|
28
|
-
version = "2.13.
|
|
28
|
+
version = "2.13.1"
|
|
29
29
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
30
|
-
checksum = "
|
|
30
|
+
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
|
31
31
|
|
|
32
32
|
[[package]]
|
|
33
33
|
name = "bumpalo"
|
|
@@ -145,7 +145,7 @@ dependencies = [
|
|
|
145
145
|
|
|
146
146
|
[[package]]
|
|
147
147
|
name = "handoff-mcp"
|
|
148
|
-
version = "0.24.
|
|
148
|
+
version = "0.24.5"
|
|
149
149
|
dependencies = [
|
|
150
150
|
"anyhow",
|
|
151
151
|
"chrono",
|
|
@@ -400,9 +400,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|
|
400
400
|
|
|
401
401
|
[[package]]
|
|
402
402
|
name = "syn"
|
|
403
|
-
version = "2.0.
|
|
403
|
+
version = "2.0.119"
|
|
404
404
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
405
|
-
checksum = "
|
|
405
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
406
406
|
dependencies = [
|
|
407
407
|
"proc-macro2",
|
|
408
408
|
"quote",
|
|
@@ -655,6 +655,6 @@ dependencies = [
|
|
|
655
655
|
|
|
656
656
|
[[package]]
|
|
657
657
|
name = "zmij"
|
|
658
|
-
version = "1.0.
|
|
658
|
+
version = "1.0.23"
|
|
659
659
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
660
|
-
checksum = "
|
|
660
|
+
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -86,8 +86,8 @@ edit. Disable anytime with `/plugin disable handoff-mcp-hooks@handoff-mcp-market
|
|
|
86
86
|
the MCP server and skills remain active.
|
|
87
87
|
|
|
88
88
|
> **Important:** The hooks require a `handoff` MCP server entry in the
|
|
89
|
-
> project's `.mcp.json`.
|
|
90
|
-
>
|
|
89
|
+
> project's `.mcp.json`. Run `handoff-mcp setup --mcp-json` in the project
|
|
90
|
+
> directory to add it automatically, or add it manually:
|
|
91
91
|
>
|
|
92
92
|
> ```json
|
|
93
93
|
> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.5",
|
|
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>",
|
package/src/main.rs
CHANGED
|
@@ -31,9 +31,11 @@ fn main() {
|
|
|
31
31
|
let uninstall = args.iter().any(|a| a == "--uninstall");
|
|
32
32
|
let mcp_json = args.iter().any(|a| a == "--mcp-json");
|
|
33
33
|
let yes = args.iter().any(|a| a == "-y" || a == "--yes");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
let global = args.iter().any(|a| a == "--global");
|
|
35
|
+
let force = args.iter().any(|a| a == "--force");
|
|
36
|
+
if let Err(e) = handoff_mcp::setup::run_setup_with_opts(
|
|
37
|
+
check, uninstall, mcp_json, yes, global, force,
|
|
38
|
+
) {
|
|
37
39
|
eprintln!("Error: {e:#}");
|
|
38
40
|
std::process::exit(1);
|
|
39
41
|
}
|
|
@@ -140,10 +142,12 @@ SERVER MODE:
|
|
|
140
142
|
handoff-mcp Start the MCP server (stdio transport)
|
|
141
143
|
|
|
142
144
|
SETUP:
|
|
143
|
-
handoff-mcp setup Install hooks +
|
|
144
|
-
handoff-mcp setup --
|
|
145
|
-
handoff-mcp setup --
|
|
146
|
-
handoff-mcp setup --
|
|
145
|
+
handoff-mcp setup Install hooks + MCP server + CLAUDE.md template
|
|
146
|
+
handoff-mcp setup --global Use ~/.claude/settings.json mcpServers (not .mcp.json)
|
|
147
|
+
handoff-mcp setup --check Check if hooks, MCP, and CLAUDE.md are configured
|
|
148
|
+
handoff-mcp setup --uninstall Remove handoff hooks and MCP entry
|
|
149
|
+
handoff-mcp setup --mcp-json Add handoff MCP server entry only (non-interactive)
|
|
150
|
+
handoff-mcp setup --force Replace existing CLAUDE.md handoff section with latest
|
|
147
151
|
handoff-mcp setup -y Skip all confirmation prompts
|
|
148
152
|
|
|
149
153
|
OPTIONS:
|
package/src/setup.rs
CHANGED
|
@@ -6,6 +6,9 @@ use serde_json::Value;
|
|
|
6
6
|
|
|
7
7
|
const HOOK_SERVER: &str = "handoff";
|
|
8
8
|
const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
|
|
9
|
+
const CLAUDE_MD_MARKER: &str = "## Session Handoff";
|
|
10
|
+
|
|
11
|
+
const CLAUDE_MD_TEMPLATE: &str = include_str!("../templates/claude-md-section.md");
|
|
9
12
|
|
|
10
13
|
fn settings_path() -> Result<PathBuf> {
|
|
11
14
|
let home = std::env::var("HOME")
|
|
@@ -19,6 +22,68 @@ fn mcp_json_path() -> PathBuf {
|
|
|
19
22
|
cwd.join(".mcp.json")
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
fn claude_md_path() -> PathBuf {
|
|
26
|
+
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
27
|
+
cwd.join("CLAUDE.md")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn has_claude_md_handoff_section(path: &Path) -> bool {
|
|
31
|
+
let Ok(text) = std::fs::read_to_string(path) else {
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
text.contains(CLAUDE_MD_MARKER)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn ensure_claude_md_section(path: &Path, force: bool) -> Result<bool> {
|
|
38
|
+
let has_section = path.exists() && has_claude_md_handoff_section(path);
|
|
39
|
+
|
|
40
|
+
if has_section && !force {
|
|
41
|
+
return Ok(false);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let mut content = if path.exists() {
|
|
45
|
+
std::fs::read_to_string(path)
|
|
46
|
+
.with_context(|| format!("failed to read {}", path.display()))?
|
|
47
|
+
} else {
|
|
48
|
+
String::from("# CLAUDE.md\n")
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if has_section {
|
|
52
|
+
content = replace_claude_md_section(&content);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if !content.ends_with('\n') {
|
|
56
|
+
content.push('\n');
|
|
57
|
+
}
|
|
58
|
+
content.push('\n');
|
|
59
|
+
content.push_str(CLAUDE_MD_TEMPLATE);
|
|
60
|
+
|
|
61
|
+
std::fs::write(path, &content)
|
|
62
|
+
.with_context(|| format!("failed to write {}", path.display()))?;
|
|
63
|
+
Ok(true)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn replace_claude_md_section(text: &str) -> String {
|
|
67
|
+
let Some(start) = text.find(CLAUDE_MD_MARKER) else {
|
|
68
|
+
return text.to_string();
|
|
69
|
+
};
|
|
70
|
+
let after_marker = start + CLAUDE_MD_MARKER.len();
|
|
71
|
+
let end = text[after_marker..]
|
|
72
|
+
.find("\n## ")
|
|
73
|
+
.map(|pos| after_marker + pos)
|
|
74
|
+
.unwrap_or(text.len());
|
|
75
|
+
let mut result = text[..start].trim_end_matches('\n').to_string();
|
|
76
|
+
let trailing = text[end..].trim_start_matches('\n');
|
|
77
|
+
if !trailing.is_empty() {
|
|
78
|
+
result.push_str("\n\n");
|
|
79
|
+
result.push_str(trailing);
|
|
80
|
+
}
|
|
81
|
+
if !result.ends_with('\n') {
|
|
82
|
+
result.push('\n');
|
|
83
|
+
}
|
|
84
|
+
result
|
|
85
|
+
}
|
|
86
|
+
|
|
22
87
|
fn build_mcp_server_entry() -> Value {
|
|
23
88
|
serde_json::json!({
|
|
24
89
|
"type": "stdio",
|
|
@@ -58,6 +123,47 @@ fn ensure_mcp_json_entry(path: &Path) -> Result<bool> {
|
|
|
58
123
|
Ok(true)
|
|
59
124
|
}
|
|
60
125
|
|
|
126
|
+
fn ensure_global_mcp_entry(settings: &mut Value) -> Result<bool> {
|
|
127
|
+
let obj = settings
|
|
128
|
+
.as_object_mut()
|
|
129
|
+
.context("settings.json root is not an object")?;
|
|
130
|
+
let servers = obj
|
|
131
|
+
.entry("mcpServers")
|
|
132
|
+
.or_insert_with(|| Value::Object(serde_json::Map::new()))
|
|
133
|
+
.as_object_mut()
|
|
134
|
+
.context("settings.json 'mcpServers' is not an object")?;
|
|
135
|
+
|
|
136
|
+
if servers.contains_key(HOOK_SERVER) {
|
|
137
|
+
return Ok(false);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
servers.insert(HOOK_SERVER.to_string(), build_mcp_server_entry());
|
|
141
|
+
Ok(true)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn has_global_mcp_entry(settings: &Value) -> bool {
|
|
145
|
+
settings
|
|
146
|
+
.get("mcpServers")
|
|
147
|
+
.and_then(|s| s.get(HOOK_SERVER))
|
|
148
|
+
.is_some()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fn remove_global_mcp_entry(settings: &mut Value) -> bool {
|
|
152
|
+
let Some(servers) = settings
|
|
153
|
+
.get_mut("mcpServers")
|
|
154
|
+
.and_then(|v| v.as_object_mut())
|
|
155
|
+
else {
|
|
156
|
+
return false;
|
|
157
|
+
};
|
|
158
|
+
let removed = servers.remove(HOOK_SERVER).is_some();
|
|
159
|
+
if removed && servers.is_empty() {
|
|
160
|
+
if let Some(obj) = settings.as_object_mut() {
|
|
161
|
+
obj.remove("mcpServers");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
removed
|
|
165
|
+
}
|
|
166
|
+
|
|
61
167
|
fn has_mcp_json_entry(path: &Path) -> bool {
|
|
62
168
|
if !path.exists() {
|
|
63
169
|
return false;
|
|
@@ -204,7 +310,7 @@ fn strip_legacy_session_start_hook(hooks_obj: &mut serde_json::Map<String, Value
|
|
|
204
310
|
}
|
|
205
311
|
|
|
206
312
|
pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
|
|
207
|
-
run_setup_with_opts(check_only, uninstall, false, false)
|
|
313
|
+
run_setup_with_opts(check_only, uninstall, false, false, false, false)
|
|
208
314
|
}
|
|
209
315
|
|
|
210
316
|
pub fn run_setup_with_opts(
|
|
@@ -212,6 +318,8 @@ pub fn run_setup_with_opts(
|
|
|
212
318
|
uninstall: bool,
|
|
213
319
|
mcp_json: bool,
|
|
214
320
|
yes: bool,
|
|
321
|
+
global: bool,
|
|
322
|
+
force: bool,
|
|
215
323
|
) -> Result<()> {
|
|
216
324
|
anyhow::ensure!(
|
|
217
325
|
!(check_only && uninstall),
|
|
@@ -222,17 +330,21 @@ pub fn run_setup_with_opts(
|
|
|
222
330
|
let mut settings = read_settings(&path)?;
|
|
223
331
|
|
|
224
332
|
if check_only {
|
|
225
|
-
return run_check(&settings, &path);
|
|
333
|
+
return run_check(&settings, &path, global);
|
|
226
334
|
}
|
|
227
335
|
|
|
228
336
|
if uninstall {
|
|
229
|
-
return run_uninstall(&mut settings, &path);
|
|
337
|
+
return run_uninstall(&mut settings, &path, global);
|
|
230
338
|
}
|
|
231
339
|
|
|
232
|
-
|
|
340
|
+
if mcp_json {
|
|
341
|
+
return run_mcp_json_only(global, &mut settings, &path);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
run_install(&mut settings, &path, yes, global, force)
|
|
233
345
|
}
|
|
234
346
|
|
|
235
|
-
fn run_check(settings: &Value, path: &Path) -> Result<()> {
|
|
347
|
+
fn run_check(settings: &Value, path: &Path, global: bool) -> Result<()> {
|
|
236
348
|
println!("Settings file: {}", path.display());
|
|
237
349
|
|
|
238
350
|
let hooks_obj = settings.get("hooks");
|
|
@@ -265,21 +377,41 @@ fn run_check(settings: &Value, path: &Path) -> Result<()> {
|
|
|
265
377
|
all_ok = false;
|
|
266
378
|
}
|
|
267
379
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
380
|
+
if global {
|
|
381
|
+
if has_global_mcp_entry(settings) {
|
|
382
|
+
println!(" settings.json mcpServers: handoff server configured (global)");
|
|
383
|
+
} else {
|
|
384
|
+
println!(" settings.json mcpServers: handoff server NOT configured (global)");
|
|
385
|
+
all_ok = false;
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
let mcp_path = mcp_json_path();
|
|
389
|
+
if has_mcp_json_entry(&mcp_path) {
|
|
390
|
+
println!(" .mcp.json: handoff server configured");
|
|
391
|
+
} else if mcp_path.exists() {
|
|
392
|
+
println!(" .mcp.json: handoff server NOT configured");
|
|
393
|
+
all_ok = false;
|
|
394
|
+
} else {
|
|
395
|
+
println!(" .mcp.json: not found (hooks need it — run `handoff-mcp setup`)");
|
|
396
|
+
all_ok = false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let claude_md = claude_md_path();
|
|
401
|
+
if has_claude_md_handoff_section(&claude_md) {
|
|
402
|
+
println!(" CLAUDE.md: handoff section present");
|
|
403
|
+
} else if claude_md.exists() {
|
|
404
|
+
println!(" CLAUDE.md: handoff section NOT present");
|
|
273
405
|
all_ok = false;
|
|
274
406
|
} else {
|
|
275
|
-
println!(" .
|
|
407
|
+
println!(" CLAUDE.md: not found");
|
|
276
408
|
all_ok = false;
|
|
277
409
|
}
|
|
278
410
|
|
|
279
411
|
if all_ok {
|
|
280
412
|
println!("\nAll hooks are configured. Memory auto-injection is active.");
|
|
281
413
|
} else {
|
|
282
|
-
println!("\
|
|
414
|
+
println!("\nSome items need attention. Run `handoff-mcp setup` to install/migrate them.");
|
|
283
415
|
}
|
|
284
416
|
|
|
285
417
|
Ok(())
|
|
@@ -297,7 +429,40 @@ fn prompt_yn(question: &str) -> bool {
|
|
|
297
429
|
trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
|
|
298
430
|
}
|
|
299
431
|
|
|
300
|
-
fn
|
|
432
|
+
fn run_mcp_json_only(global: bool, settings: &mut Value, settings_path: &Path) -> Result<()> {
|
|
433
|
+
if global {
|
|
434
|
+
if has_global_mcp_entry(settings) {
|
|
435
|
+
println!(" settings.json mcpServers: handoff server already configured (global). Nothing to do.");
|
|
436
|
+
return Ok(());
|
|
437
|
+
}
|
|
438
|
+
let added = ensure_global_mcp_entry(settings)?;
|
|
439
|
+
if added {
|
|
440
|
+
write_settings(settings_path, settings)?;
|
|
441
|
+
println!(" settings.json mcpServers: added handoff server entry (global)");
|
|
442
|
+
println!("\nRestart Claude Code for changes to take effect.");
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
let mcp_path = mcp_json_path();
|
|
446
|
+
if has_mcp_json_entry(&mcp_path) {
|
|
447
|
+
println!(" .mcp.json: handoff server already configured. Nothing to do.");
|
|
448
|
+
return Ok(());
|
|
449
|
+
}
|
|
450
|
+
let added = ensure_mcp_json_entry(&mcp_path)?;
|
|
451
|
+
if added {
|
|
452
|
+
println!(" .mcp.json: added handoff server entry");
|
|
453
|
+
println!("\nRestart Claude Code for changes to take effect.");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
Ok(())
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
fn run_install(
|
|
460
|
+
settings: &mut Value,
|
|
461
|
+
path: &Path,
|
|
462
|
+
yes: bool,
|
|
463
|
+
global: bool,
|
|
464
|
+
force: bool,
|
|
465
|
+
) -> Result<()> {
|
|
301
466
|
let obj = settings
|
|
302
467
|
.as_object_mut()
|
|
303
468
|
.context("settings.json root is not an object")?;
|
|
@@ -346,7 +511,21 @@ fn run_install(settings: &mut Value, path: &Path, mcp_json: bool, yes: bool) ->
|
|
|
346
511
|
);
|
|
347
512
|
}
|
|
348
513
|
|
|
349
|
-
|
|
514
|
+
let mut settings_dirty = installed > 0 || migrated;
|
|
515
|
+
|
|
516
|
+
if global {
|
|
517
|
+
if has_global_mcp_entry(settings) {
|
|
518
|
+
println!(" settings.json mcpServers: handoff server already configured (global)");
|
|
519
|
+
} else {
|
|
520
|
+
let added = ensure_global_mcp_entry(settings)?;
|
|
521
|
+
if added {
|
|
522
|
+
println!(" settings.json mcpServers: added handoff server entry (global)");
|
|
523
|
+
settings_dirty = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if settings_dirty {
|
|
350
529
|
write_settings(path, settings)?;
|
|
351
530
|
println!("\nWrote {path}", path = path.display());
|
|
352
531
|
if installed > 0 {
|
|
@@ -359,81 +538,127 @@ fn run_install(settings: &mut Value, path: &Path, mcp_json: bool, yes: bool) ->
|
|
|
359
538
|
println!("\nAll hooks already installed.");
|
|
360
539
|
}
|
|
361
540
|
|
|
362
|
-
|
|
363
|
-
|
|
541
|
+
if !global {
|
|
542
|
+
let mcp_path = mcp_json_path();
|
|
543
|
+
let needs_mcp = !has_mcp_json_entry(&mcp_path);
|
|
364
544
|
|
|
365
|
-
|
|
366
|
-
|
|
545
|
+
if needs_mcp {
|
|
546
|
+
let should_write = if yes {
|
|
547
|
+
true
|
|
548
|
+
} else {
|
|
549
|
+
println!();
|
|
550
|
+
prompt_yn("Add handoff server to .mcp.json? (required for hooks)")
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if should_write {
|
|
554
|
+
let added = ensure_mcp_json_entry(&mcp_path)?;
|
|
555
|
+
if added {
|
|
556
|
+
println!(" .mcp.json: added handoff server entry");
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
println!(
|
|
560
|
+
" .mcp.json: skipped. Hooks will not work without a handoff server.\n \
|
|
561
|
+
Run `handoff-mcp setup --mcp-json` later, or add it manually."
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
println!(" .mcp.json: handoff server already configured");
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// CLAUDE.md template
|
|
570
|
+
let claude_md = claude_md_path();
|
|
571
|
+
let has_section = has_claude_md_handoff_section(&claude_md);
|
|
572
|
+
if has_section && !force {
|
|
573
|
+
println!(" CLAUDE.md: handoff section already present");
|
|
574
|
+
} else {
|
|
575
|
+
let prompt_msg = if has_section {
|
|
576
|
+
"Replace existing session handoff section in CLAUDE.md?"
|
|
577
|
+
} else {
|
|
578
|
+
"Add session handoff section to CLAUDE.md?"
|
|
579
|
+
};
|
|
580
|
+
let should_write = if yes || force {
|
|
367
581
|
true
|
|
368
582
|
} else {
|
|
369
583
|
println!();
|
|
370
|
-
prompt_yn(
|
|
584
|
+
prompt_yn(prompt_msg)
|
|
371
585
|
};
|
|
372
586
|
|
|
373
587
|
if should_write {
|
|
374
|
-
let
|
|
588
|
+
let existed = claude_md.exists();
|
|
589
|
+
let added = ensure_claude_md_section(&claude_md, force)?;
|
|
375
590
|
if added {
|
|
376
|
-
|
|
591
|
+
let verb = if has_section {
|
|
592
|
+
"replaced"
|
|
593
|
+
} else if existed {
|
|
594
|
+
"appended to"
|
|
595
|
+
} else {
|
|
596
|
+
"created"
|
|
597
|
+
};
|
|
598
|
+
println!(" CLAUDE.md: session handoff section {verb}");
|
|
377
599
|
}
|
|
378
600
|
} else {
|
|
379
|
-
println!(
|
|
380
|
-
" .mcp.json: skipped. Hooks will not work without a handoff server.\n \
|
|
381
|
-
Run `handoff-mcp setup --mcp-json` later, or add it manually."
|
|
382
|
-
);
|
|
601
|
+
println!(" CLAUDE.md: skipped");
|
|
383
602
|
}
|
|
384
|
-
} else {
|
|
385
|
-
println!(" .mcp.json: handoff server already configured");
|
|
386
603
|
}
|
|
387
604
|
|
|
388
605
|
println!("\nRestart Claude Code for changes to take effect.");
|
|
389
606
|
Ok(())
|
|
390
607
|
}
|
|
391
608
|
|
|
392
|
-
fn run_uninstall(settings: &mut Value, path: &Path) -> Result<()> {
|
|
393
|
-
let
|
|
394
|
-
println!("No hooks configured. Nothing to remove.");
|
|
395
|
-
return Ok(());
|
|
396
|
-
};
|
|
609
|
+
fn run_uninstall(settings: &mut Value, path: &Path, global: bool) -> Result<()> {
|
|
610
|
+
let mut anything_removed = false;
|
|
397
611
|
|
|
398
|
-
let
|
|
399
|
-
|
|
612
|
+
if let Some(hooks_obj) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
|
613
|
+
let events: Vec<String> = hooks_obj.keys().cloned().collect();
|
|
614
|
+
let mut removed = 0u32;
|
|
400
615
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
let before = arr.len();
|
|
407
|
-
arr.retain(|entry| {
|
|
408
|
-
let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
|
|
409
|
-
return true;
|
|
616
|
+
for event in &events {
|
|
617
|
+
let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
|
|
618
|
+
continue;
|
|
410
619
|
};
|
|
411
|
-
!hooks
|
|
412
|
-
.iter()
|
|
413
|
-
.any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
|
|
414
|
-
});
|
|
415
620
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
621
|
+
let before = arr.len();
|
|
622
|
+
arr.retain(|entry| {
|
|
623
|
+
let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
|
|
624
|
+
return true;
|
|
625
|
+
};
|
|
626
|
+
!hooks
|
|
627
|
+
.iter()
|
|
628
|
+
.any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
let after = arr.len();
|
|
632
|
+
if before != after {
|
|
633
|
+
println!(" {event}: removed handoff hook(s)");
|
|
634
|
+
removed += 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if arr.is_empty() {
|
|
638
|
+
hooks_obj.remove(event);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if hooks_obj.is_empty() {
|
|
643
|
+
if let Some(obj) = settings.as_object_mut() {
|
|
644
|
+
obj.remove("hooks");
|
|
645
|
+
}
|
|
420
646
|
}
|
|
421
647
|
|
|
422
|
-
if
|
|
423
|
-
|
|
648
|
+
if removed > 0 {
|
|
649
|
+
anything_removed = true;
|
|
650
|
+
println!("{removed} hook event(s) cleaned up.");
|
|
424
651
|
}
|
|
425
652
|
}
|
|
426
653
|
|
|
427
|
-
if
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
654
|
+
if global && remove_global_mcp_entry(settings) {
|
|
655
|
+
println!(" settings.json mcpServers: removed handoff server entry (global)");
|
|
656
|
+
anything_removed = true;
|
|
431
657
|
}
|
|
432
658
|
|
|
433
|
-
if
|
|
659
|
+
if anything_removed {
|
|
434
660
|
write_settings(path, settings)?;
|
|
435
661
|
println!("\nWrote {path}", path = path.display());
|
|
436
|
-
println!("{removed} hook event(s) cleaned up.");
|
|
437
662
|
println!("\nRestart Claude Code for changes to take effect.");
|
|
438
663
|
} else {
|
|
439
664
|
println!("No handoff hooks found. Nothing to remove.");
|
|
@@ -482,7 +707,7 @@ mod tests {
|
|
|
482
707
|
let path = dir.path().join("settings.json");
|
|
483
708
|
|
|
484
709
|
let mut settings = Value::Object(serde_json::Map::new());
|
|
485
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
710
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
486
711
|
|
|
487
712
|
let written: Value =
|
|
488
713
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -505,7 +730,7 @@ mod tests {
|
|
|
505
730
|
}
|
|
506
731
|
});
|
|
507
732
|
|
|
508
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
733
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
509
734
|
|
|
510
735
|
let written: Value =
|
|
511
736
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -520,10 +745,10 @@ mod tests {
|
|
|
520
745
|
let path = dir.path().join("settings.json");
|
|
521
746
|
|
|
522
747
|
let mut settings = Value::Object(serde_json::Map::new());
|
|
523
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
748
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
524
749
|
|
|
525
750
|
let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
526
|
-
run_install(&mut settings2, &path, false, false).unwrap();
|
|
751
|
+
run_install(&mut settings2, &path, false, false, false).unwrap();
|
|
527
752
|
|
|
528
753
|
let written: Value =
|
|
529
754
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -541,11 +766,11 @@ mod tests {
|
|
|
541
766
|
let path = dir.path().join("settings.json");
|
|
542
767
|
|
|
543
768
|
let mut settings = Value::Object(serde_json::Map::new());
|
|
544
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
769
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
545
770
|
|
|
546
771
|
let mut settings2: Value =
|
|
547
772
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
548
|
-
run_uninstall(&mut settings2, &path).unwrap();
|
|
773
|
+
run_uninstall(&mut settings2, &path, false).unwrap();
|
|
549
774
|
|
|
550
775
|
let written: Value =
|
|
551
776
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -571,7 +796,7 @@ mod tests {
|
|
|
571
796
|
std::fs::write(&path, original).unwrap();
|
|
572
797
|
|
|
573
798
|
let mut settings: Value = serde_json::from_str(original).unwrap();
|
|
574
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
799
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
575
800
|
|
|
576
801
|
let written = std::fs::read_to_string(&path).unwrap();
|
|
577
802
|
let env_pos = written.find("\"env\"").unwrap();
|
|
@@ -612,7 +837,7 @@ mod tests {
|
|
|
612
837
|
}
|
|
613
838
|
});
|
|
614
839
|
|
|
615
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
840
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
616
841
|
|
|
617
842
|
let written: Value =
|
|
618
843
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -639,7 +864,7 @@ mod tests {
|
|
|
639
864
|
}
|
|
640
865
|
});
|
|
641
866
|
|
|
642
|
-
run_install(&mut settings, &path, false, false).unwrap();
|
|
867
|
+
run_install(&mut settings, &path, false, false, false).unwrap();
|
|
643
868
|
|
|
644
869
|
let written: Value =
|
|
645
870
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -687,7 +912,7 @@ mod tests {
|
|
|
687
912
|
]
|
|
688
913
|
}
|
|
689
914
|
});
|
|
690
|
-
run_uninstall(&mut settings, &path).unwrap();
|
|
915
|
+
run_uninstall(&mut settings, &path, false).unwrap();
|
|
691
916
|
|
|
692
917
|
let written: Value =
|
|
693
918
|
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
@@ -764,4 +989,224 @@ mod tests {
|
|
|
764
989
|
.unwrap();
|
|
765
990
|
assert!(!has_mcp_json_entry(&no_handoff));
|
|
766
991
|
}
|
|
992
|
+
|
|
993
|
+
// --- Global MCP entry tests ---
|
|
994
|
+
|
|
995
|
+
#[test]
|
|
996
|
+
fn ensure_global_mcp_adds_to_empty_settings() {
|
|
997
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
998
|
+
let added = ensure_global_mcp_entry(&mut settings).unwrap();
|
|
999
|
+
assert!(added);
|
|
1000
|
+
assert!(has_global_mcp_entry(&settings));
|
|
1001
|
+
assert!(settings["mcpServers"]["handoff"]["command"]
|
|
1002
|
+
.as_str()
|
|
1003
|
+
.unwrap()
|
|
1004
|
+
.contains("handoff-mcp"));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
#[test]
|
|
1008
|
+
fn ensure_global_mcp_preserves_existing_servers() {
|
|
1009
|
+
let mut settings = serde_json::json!({
|
|
1010
|
+
"mcpServers": {
|
|
1011
|
+
"context7": {"type": "stdio", "command": "npx"}
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
let added = ensure_global_mcp_entry(&mut settings).unwrap();
|
|
1015
|
+
assert!(added);
|
|
1016
|
+
assert!(settings["mcpServers"].get("context7").is_some());
|
|
1017
|
+
assert!(settings["mcpServers"].get("handoff").is_some());
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
#[test]
|
|
1021
|
+
fn ensure_global_mcp_skips_when_present() {
|
|
1022
|
+
let mut settings = serde_json::json!({
|
|
1023
|
+
"mcpServers": {
|
|
1024
|
+
"handoff": {"type": "stdio", "command": "handoff-mcp"}
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
let added = ensure_global_mcp_entry(&mut settings).unwrap();
|
|
1028
|
+
assert!(!added);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#[test]
|
|
1032
|
+
fn remove_global_mcp_entry_removes_handoff() {
|
|
1033
|
+
let mut settings = serde_json::json!({
|
|
1034
|
+
"mcpServers": {
|
|
1035
|
+
"handoff": {"type": "stdio"},
|
|
1036
|
+
"context7": {"type": "stdio"}
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
assert!(remove_global_mcp_entry(&mut settings));
|
|
1040
|
+
assert!(settings["mcpServers"].get("context7").is_some());
|
|
1041
|
+
assert!(settings["mcpServers"].get("handoff").is_none());
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
#[test]
|
|
1045
|
+
fn remove_global_mcp_entry_cleans_empty_section() {
|
|
1046
|
+
let mut settings = serde_json::json!({
|
|
1047
|
+
"mcpServers": {
|
|
1048
|
+
"handoff": {"type": "stdio"}
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
assert!(remove_global_mcp_entry(&mut settings));
|
|
1052
|
+
assert!(settings.get("mcpServers").is_none());
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
#[test]
|
|
1056
|
+
fn remove_global_mcp_entry_returns_false_when_absent() {
|
|
1057
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
1058
|
+
assert!(!remove_global_mcp_entry(&mut settings));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
#[test]
|
|
1062
|
+
fn uninstall_with_global_removes_mcp_entry() {
|
|
1063
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1064
|
+
let path = dir.path().join("settings.json");
|
|
1065
|
+
|
|
1066
|
+
let mut settings = serde_json::json!({
|
|
1067
|
+
"hooks": {
|
|
1068
|
+
"UserPromptSubmit": [{
|
|
1069
|
+
"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
|
|
1070
|
+
}]
|
|
1071
|
+
},
|
|
1072
|
+
"mcpServers": {
|
|
1073
|
+
"handoff": {"type": "stdio", "command": "handoff-mcp"}
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
run_uninstall(&mut settings, &path, true).unwrap();
|
|
1078
|
+
|
|
1079
|
+
let written: Value =
|
|
1080
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
1081
|
+
assert!(written.get("hooks").is_none());
|
|
1082
|
+
assert!(written.get("mcpServers").is_none());
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// --- CLAUDE.md tests ---
|
|
1086
|
+
|
|
1087
|
+
#[test]
|
|
1088
|
+
fn ensure_claude_md_creates_new_file() {
|
|
1089
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1090
|
+
let path = dir.path().join("CLAUDE.md");
|
|
1091
|
+
assert!(!path.exists());
|
|
1092
|
+
|
|
1093
|
+
let added = ensure_claude_md_section(&path, false).unwrap();
|
|
1094
|
+
assert!(added);
|
|
1095
|
+
|
|
1096
|
+
let content = std::fs::read_to_string(&path).unwrap();
|
|
1097
|
+
assert!(content.contains(CLAUDE_MD_MARKER));
|
|
1098
|
+
assert!(content.contains("handoff_load_context"));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
#[test]
|
|
1102
|
+
fn ensure_claude_md_appends_to_existing() {
|
|
1103
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1104
|
+
let path = dir.path().join("CLAUDE.md");
|
|
1105
|
+
std::fs::write(&path, "# My Project\n\nSome existing content.\n").unwrap();
|
|
1106
|
+
|
|
1107
|
+
let added = ensure_claude_md_section(&path, false).unwrap();
|
|
1108
|
+
assert!(added);
|
|
1109
|
+
|
|
1110
|
+
let content = std::fs::read_to_string(&path).unwrap();
|
|
1111
|
+
assert!(content.starts_with("# My Project"));
|
|
1112
|
+
assert!(content.contains("Some existing content."));
|
|
1113
|
+
assert!(content.contains(CLAUDE_MD_MARKER));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
#[test]
|
|
1117
|
+
fn ensure_claude_md_skips_when_marker_present() {
|
|
1118
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1119
|
+
let path = dir.path().join("CLAUDE.md");
|
|
1120
|
+
std::fs::write(
|
|
1121
|
+
&path,
|
|
1122
|
+
"# Project\n\n## Session Handoff\n\nCustom handoff instructions.\n",
|
|
1123
|
+
)
|
|
1124
|
+
.unwrap();
|
|
1125
|
+
|
|
1126
|
+
let added = ensure_claude_md_section(&path, false).unwrap();
|
|
1127
|
+
assert!(!added);
|
|
1128
|
+
|
|
1129
|
+
let content = std::fs::read_to_string(&path).unwrap();
|
|
1130
|
+
assert!(content.contains("Custom handoff instructions."));
|
|
1131
|
+
assert!(!content.contains("handoff_load_context"));
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
#[test]
|
|
1135
|
+
fn has_claude_md_handoff_section_detects_correctly() {
|
|
1136
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1137
|
+
|
|
1138
|
+
let absent = dir.path().join("absent.md");
|
|
1139
|
+
assert!(!has_claude_md_handoff_section(&absent));
|
|
1140
|
+
|
|
1141
|
+
let present = dir.path().join("present.md");
|
|
1142
|
+
std::fs::write(&present, "# X\n\n## Session Handoff\n\nfoo\n").unwrap();
|
|
1143
|
+
assert!(has_claude_md_handoff_section(&present));
|
|
1144
|
+
|
|
1145
|
+
let without = dir.path().join("without.md");
|
|
1146
|
+
std::fs::write(&without, "# X\n\nSome content\n").unwrap();
|
|
1147
|
+
assert!(!has_claude_md_handoff_section(&without));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// --- Force replace tests ---
|
|
1151
|
+
|
|
1152
|
+
#[test]
|
|
1153
|
+
fn ensure_claude_md_force_replaces_existing_section() {
|
|
1154
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1155
|
+
let path = dir.path().join("CLAUDE.md");
|
|
1156
|
+
std::fs::write(
|
|
1157
|
+
&path,
|
|
1158
|
+
"# Project\n\n## Session Handoff\n\nOld stale content here.\n",
|
|
1159
|
+
)
|
|
1160
|
+
.unwrap();
|
|
1161
|
+
|
|
1162
|
+
let added = ensure_claude_md_section(&path, false).unwrap();
|
|
1163
|
+
assert!(!added, "should skip without force");
|
|
1164
|
+
|
|
1165
|
+
let added = ensure_claude_md_section(&path, true).unwrap();
|
|
1166
|
+
assert!(added, "should replace with force");
|
|
1167
|
+
|
|
1168
|
+
let content = std::fs::read_to_string(&path).unwrap();
|
|
1169
|
+
assert!(!content.contains("Old stale content"));
|
|
1170
|
+
assert!(content.contains("handoff_load_context"));
|
|
1171
|
+
assert!(content.starts_with("# Project"));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
#[test]
|
|
1175
|
+
fn force_replace_preserves_sections_after() {
|
|
1176
|
+
let dir = tempfile::tempdir().unwrap();
|
|
1177
|
+
let path = dir.path().join("CLAUDE.md");
|
|
1178
|
+
std::fs::write(
|
|
1179
|
+
&path,
|
|
1180
|
+
"# Project\n\n## Session Handoff\n\nOld content.\n\n## Other Section\n\nKeep this.\n",
|
|
1181
|
+
)
|
|
1182
|
+
.unwrap();
|
|
1183
|
+
|
|
1184
|
+
let added = ensure_claude_md_section(&path, true).unwrap();
|
|
1185
|
+
assert!(added);
|
|
1186
|
+
|
|
1187
|
+
let content = std::fs::read_to_string(&path).unwrap();
|
|
1188
|
+
assert!(content.contains("## Other Section"));
|
|
1189
|
+
assert!(content.contains("Keep this."));
|
|
1190
|
+
assert!(content.contains("handoff_load_context"));
|
|
1191
|
+
assert!(!content.contains("Old content."));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
#[test]
|
|
1195
|
+
fn replace_claude_md_section_strips_only_handoff() {
|
|
1196
|
+
let text = "# Top\n\n## Session Handoff\n\nOld stuff.\n\n## Build\n\ncargo test\n";
|
|
1197
|
+
let result = replace_claude_md_section(text);
|
|
1198
|
+
assert!(!result.contains("Old stuff."));
|
|
1199
|
+
assert!(!result.contains("Session Handoff"));
|
|
1200
|
+
assert!(result.contains("# Top"));
|
|
1201
|
+
assert!(result.contains("## Build"));
|
|
1202
|
+
assert!(result.contains("cargo test"));
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
#[test]
|
|
1206
|
+
fn replace_claude_md_section_handles_eof() {
|
|
1207
|
+
let text = "# Top\n\n## Session Handoff\n\nOnly section.\n";
|
|
1208
|
+
let result = replace_claude_md_section(text);
|
|
1209
|
+
assert!(!result.contains("Only section."));
|
|
1210
|
+
assert!(result.contains("# Top"));
|
|
1211
|
+
}
|
|
767
1212
|
}
|