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 CHANGED
@@ -25,9 +25,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
25
25
 
26
26
  [[package]]
27
27
  name = "bitflags"
28
- version = "2.13.0"
28
+ version = "2.13.1"
29
29
  source = "registry+https://github.com/rust-lang/crates.io-index"
30
- checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
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.3"
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.118"
403
+ version = "2.0.119"
404
404
  source = "registry+https://github.com/rust-lang/crates.io-index"
405
- checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
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.22"
658
+ version = "1.0.23"
659
659
  source = "registry+https://github.com/rust-lang/crates.io-index"
660
- checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
660
+ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.24.3"
3
+ version = "0.24.5"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
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`. If your project doesn't already have one (e.g. you
90
- > only use the plugin), add it:
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",
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
- if let Err(e) =
35
- handoff_mcp::setup::run_setup_with_opts(check, uninstall, mcp_json, yes)
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 + add handoff server to .mcp.json
144
- handoff-mcp setup --check Check if hooks and .mcp.json are configured
145
- handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
146
- handoff-mcp setup --mcp-json Add handoff server to .mcp.json (non-interactive)
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
- run_install(&mut settings, &path, mcp_json, yes)
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
- let mcp_path = mcp_json_path();
269
- if has_mcp_json_entry(&mcp_path) {
270
- println!(" .mcp.json: handoff server configured");
271
- } else if mcp_path.exists() {
272
- println!(" .mcp.json: handoff server NOT configured");
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!(" .mcp.json: not found (hooks need it — run `handoff-mcp setup`)");
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!("\nHooks need attention. Run `handoff-mcp setup` to install/migrate them.");
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 run_install(settings: &mut Value, path: &Path, mcp_json: bool, yes: bool) -> Result<()> {
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
- if installed > 0 || migrated {
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
- let mcp_path = mcp_json_path();
363
- let needs_mcp = !has_mcp_json_entry(&mcp_path);
541
+ if !global {
542
+ let mcp_path = mcp_json_path();
543
+ let needs_mcp = !has_mcp_json_entry(&mcp_path);
364
544
 
365
- if needs_mcp {
366
- let should_write = if mcp_json || yes {
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("Add handoff server to .mcp.json? (required for hooks)")
584
+ prompt_yn(prompt_msg)
371
585
  };
372
586
 
373
587
  if should_write {
374
- let added = ensure_mcp_json_entry(&mcp_path)?;
588
+ let existed = claude_md.exists();
589
+ let added = ensure_claude_md_section(&claude_md, force)?;
375
590
  if added {
376
- println!(" .mcp.json: added handoff server entry");
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 Some(hooks_obj) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
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 events: Vec<String> = hooks_obj.keys().cloned().collect();
399
- let mut removed = 0u32;
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
- for event in &events {
402
- let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
403
- continue;
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
- let after = arr.len();
417
- if before != after {
418
- println!(" {event}: removed handoff hook(s)");
419
- removed += 1;
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 arr.is_empty() {
423
- hooks_obj.remove(event);
648
+ if removed > 0 {
649
+ anything_removed = true;
650
+ println!("{removed} hook event(s) cleaned up.");
424
651
  }
425
652
  }
426
653
 
427
- if hooks_obj.is_empty() {
428
- if let Some(obj) = settings.as_object_mut() {
429
- obj.remove("hooks");
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 removed > 0 {
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
  }