handoff-mcp-server 0.24.4 → 0.24.6

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.4"
148
+ version = "0.24.6"
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.4"
3
+ version = "0.24.6"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.24.4",
3
+ "version": "0.24.6",
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:
@@ -130,11 +130,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
130
130
  if let Some(active_session) = target {
131
131
  let sid = active_session.id.clone().unwrap_or_default();
132
132
  if keep_active {
133
- let updated_path = update_active_session(&sessions_dir, &sid, &handoff_updates)?;
133
+ let updated_path =
134
+ update_active_session(&sessions_dir, &sid, &handoff_updates, Some(arguments))?;
134
135
  (0, updated_path, Some(sid))
135
136
  } else {
136
- let closed_path =
137
- update_and_close_active_session(&sessions_dir, &sid, &handoff_updates)?;
137
+ let closed_path = update_and_close_active_session(
138
+ &sessions_dir,
139
+ &sid,
140
+ &handoff_updates,
141
+ Some(arguments),
142
+ )?;
138
143
  (
139
144
  if closed_path.is_some() { 1 } else { 0 },
140
145
  closed_path,
@@ -153,11 +153,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
153
153
  Ok(msg)
154
154
  }
155
155
 
156
- fn truncate(s: &str, max: usize) -> String {
157
- if s.len() <= max {
158
- s.to_string()
156
+ fn truncate(s: &str, max_chars: usize) -> String {
157
+ let mut chars = s.chars();
158
+ let truncated: String = chars.by_ref().take(max_chars).collect();
159
+ if chars.next().is_some() {
160
+ format!("{truncated}...")
159
161
  } else {
160
- format!("{}...", &s[..max])
162
+ truncated
161
163
  }
162
164
  }
163
165
 
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,21 +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 {
233
- return run_mcp_json_only();
341
+ return run_mcp_json_only(global, &mut settings, &path);
234
342
  }
235
343
 
236
- run_install(&mut settings, &path, yes)
344
+ run_install(&mut settings, &path, yes, global, force)
237
345
  }
238
346
 
239
- fn run_check(settings: &Value, path: &Path) -> Result<()> {
347
+ fn run_check(settings: &Value, path: &Path, global: bool) -> Result<()> {
240
348
  println!("Settings file: {}", path.display());
241
349
 
242
350
  let hooks_obj = settings.get("hooks");
@@ -269,21 +377,41 @@ fn run_check(settings: &Value, path: &Path) -> Result<()> {
269
377
  all_ok = false;
270
378
  }
271
379
 
272
- let mcp_path = mcp_json_path();
273
- if has_mcp_json_entry(&mcp_path) {
274
- println!(" .mcp.json: handoff server configured");
275
- } else if mcp_path.exists() {
276
- 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");
277
405
  all_ok = false;
278
406
  } else {
279
- println!(" .mcp.json: not found (hooks need it — run `handoff-mcp setup`)");
407
+ println!(" CLAUDE.md: not found");
280
408
  all_ok = false;
281
409
  }
282
410
 
283
411
  if all_ok {
284
412
  println!("\nAll hooks are configured. Memory auto-injection is active.");
285
413
  } else {
286
- 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.");
287
415
  }
288
416
 
289
417
  Ok(())
@@ -301,21 +429,40 @@ fn prompt_yn(question: &str) -> bool {
301
429
  trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
302
430
  }
303
431
 
304
- fn run_mcp_json_only() -> Result<()> {
305
- let mcp_path = mcp_json_path();
306
- if has_mcp_json_entry(&mcp_path) {
307
- println!(" .mcp.json: handoff server already configured. Nothing to do.");
308
- return Ok(());
309
- }
310
- let added = ensure_mcp_json_entry(&mcp_path)?;
311
- if added {
312
- println!(" .mcp.json: added handoff server entry");
313
- println!("\nRestart Claude Code for changes to take effect.");
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
+ }
314
455
  }
315
456
  Ok(())
316
457
  }
317
458
 
318
- fn run_install(settings: &mut Value, path: &Path, yes: bool) -> Result<()> {
459
+ fn run_install(
460
+ settings: &mut Value,
461
+ path: &Path,
462
+ yes: bool,
463
+ global: bool,
464
+ force: bool,
465
+ ) -> Result<()> {
319
466
  let obj = settings
320
467
  .as_object_mut()
321
468
  .context("settings.json root is not an object")?;
@@ -364,7 +511,21 @@ fn run_install(settings: &mut Value, path: &Path, yes: bool) -> Result<()> {
364
511
  );
365
512
  }
366
513
 
367
- 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 {
368
529
  write_settings(path, settings)?;
369
530
  println!("\nWrote {path}", path = path.display());
370
531
  if installed > 0 {
@@ -377,81 +538,127 @@ fn run_install(settings: &mut Value, path: &Path, yes: bool) -> Result<()> {
377
538
  println!("\nAll hooks already installed.");
378
539
  }
379
540
 
380
- let mcp_path = mcp_json_path();
381
- 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);
382
544
 
383
- if needs_mcp {
384
- let should_write = if 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 {
385
581
  true
386
582
  } else {
387
583
  println!();
388
- prompt_yn("Add handoff server to .mcp.json? (required for hooks)")
584
+ prompt_yn(prompt_msg)
389
585
  };
390
586
 
391
587
  if should_write {
392
- let added = ensure_mcp_json_entry(&mcp_path)?;
588
+ let existed = claude_md.exists();
589
+ let added = ensure_claude_md_section(&claude_md, force)?;
393
590
  if added {
394
- 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}");
395
599
  }
396
600
  } else {
397
- println!(
398
- " .mcp.json: skipped. Hooks will not work without a handoff server.\n \
399
- Run `handoff-mcp setup --mcp-json` later, or add it manually."
400
- );
601
+ println!(" CLAUDE.md: skipped");
401
602
  }
402
- } else {
403
- println!(" .mcp.json: handoff server already configured");
404
603
  }
405
604
 
406
605
  println!("\nRestart Claude Code for changes to take effect.");
407
606
  Ok(())
408
607
  }
409
608
 
410
- fn run_uninstall(settings: &mut Value, path: &Path) -> Result<()> {
411
- let Some(hooks_obj) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
412
- println!("No hooks configured. Nothing to remove.");
413
- return Ok(());
414
- };
609
+ fn run_uninstall(settings: &mut Value, path: &Path, global: bool) -> Result<()> {
610
+ let mut anything_removed = false;
415
611
 
416
- let events: Vec<String> = hooks_obj.keys().cloned().collect();
417
- 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;
418
615
 
419
- for event in &events {
420
- let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
421
- continue;
422
- };
423
-
424
- let before = arr.len();
425
- arr.retain(|entry| {
426
- let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
427
- 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;
428
619
  };
429
- !hooks
430
- .iter()
431
- .any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
432
- });
433
620
 
434
- let after = arr.len();
435
- if before != after {
436
- println!(" {event}: removed handoff hook(s)");
437
- 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
+ }
438
640
  }
439
641
 
440
- if arr.is_empty() {
441
- hooks_obj.remove(event);
642
+ if hooks_obj.is_empty() {
643
+ if let Some(obj) = settings.as_object_mut() {
644
+ obj.remove("hooks");
645
+ }
442
646
  }
443
- }
444
647
 
445
- if hooks_obj.is_empty() {
446
- if let Some(obj) = settings.as_object_mut() {
447
- obj.remove("hooks");
648
+ if removed > 0 {
649
+ anything_removed = true;
650
+ println!("{removed} hook event(s) cleaned up.");
448
651
  }
449
652
  }
450
653
 
451
- if removed > 0 {
654
+ if global && remove_global_mcp_entry(settings) {
655
+ println!(" settings.json mcpServers: removed handoff server entry (global)");
656
+ anything_removed = true;
657
+ }
658
+
659
+ if anything_removed {
452
660
  write_settings(path, settings)?;
453
661
  println!("\nWrote {path}", path = path.display());
454
- println!("{removed} hook event(s) cleaned up.");
455
662
  println!("\nRestart Claude Code for changes to take effect.");
456
663
  } else {
457
664
  println!("No handoff hooks found. Nothing to remove.");
@@ -500,7 +707,7 @@ mod tests {
500
707
  let path = dir.path().join("settings.json");
501
708
 
502
709
  let mut settings = Value::Object(serde_json::Map::new());
503
- run_install(&mut settings, &path, false).unwrap();
710
+ run_install(&mut settings, &path, false, false, false).unwrap();
504
711
 
505
712
  let written: Value =
506
713
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -523,7 +730,7 @@ mod tests {
523
730
  }
524
731
  });
525
732
 
526
- run_install(&mut settings, &path, false).unwrap();
733
+ run_install(&mut settings, &path, false, false, false).unwrap();
527
734
 
528
735
  let written: Value =
529
736
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -538,10 +745,10 @@ mod tests {
538
745
  let path = dir.path().join("settings.json");
539
746
 
540
747
  let mut settings = Value::Object(serde_json::Map::new());
541
- run_install(&mut settings, &path, false).unwrap();
748
+ run_install(&mut settings, &path, false, false, false).unwrap();
542
749
 
543
750
  let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
544
- run_install(&mut settings2, &path, false).unwrap();
751
+ run_install(&mut settings2, &path, false, false, false).unwrap();
545
752
 
546
753
  let written: Value =
547
754
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -559,11 +766,11 @@ mod tests {
559
766
  let path = dir.path().join("settings.json");
560
767
 
561
768
  let mut settings = Value::Object(serde_json::Map::new());
562
- run_install(&mut settings, &path, false).unwrap();
769
+ run_install(&mut settings, &path, false, false, false).unwrap();
563
770
 
564
771
  let mut settings2: Value =
565
772
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
566
- run_uninstall(&mut settings2, &path).unwrap();
773
+ run_uninstall(&mut settings2, &path, false).unwrap();
567
774
 
568
775
  let written: Value =
569
776
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -589,7 +796,7 @@ mod tests {
589
796
  std::fs::write(&path, original).unwrap();
590
797
 
591
798
  let mut settings: Value = serde_json::from_str(original).unwrap();
592
- run_install(&mut settings, &path, false).unwrap();
799
+ run_install(&mut settings, &path, false, false, false).unwrap();
593
800
 
594
801
  let written = std::fs::read_to_string(&path).unwrap();
595
802
  let env_pos = written.find("\"env\"").unwrap();
@@ -630,7 +837,7 @@ mod tests {
630
837
  }
631
838
  });
632
839
 
633
- run_install(&mut settings, &path, false).unwrap();
840
+ run_install(&mut settings, &path, false, false, false).unwrap();
634
841
 
635
842
  let written: Value =
636
843
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -657,7 +864,7 @@ mod tests {
657
864
  }
658
865
  });
659
866
 
660
- run_install(&mut settings, &path, false).unwrap();
867
+ run_install(&mut settings, &path, false, false, false).unwrap();
661
868
 
662
869
  let written: Value =
663
870
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -705,7 +912,7 @@ mod tests {
705
912
  ]
706
913
  }
707
914
  });
708
- run_uninstall(&mut settings, &path).unwrap();
915
+ run_uninstall(&mut settings, &path, false).unwrap();
709
916
 
710
917
  let written: Value =
711
918
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -782,4 +989,224 @@ mod tests {
782
989
  .unwrap();
783
990
  assert!(!has_mcp_json_entry(&no_handoff));
784
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
+ }
785
1212
  }
@@ -384,18 +384,37 @@ pub fn read_latest_closed_session(sessions_dir: &Path) -> Result<Option<SessionD
384
384
  Ok(sessions.into_iter().last())
385
385
  }
386
386
 
387
- fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
387
+ fn apply_session_updates(
388
+ data: &mut SessionData,
389
+ updates: &SessionData,
390
+ provided_keys: Option<&Value>,
391
+ ) {
388
392
  data.summary = updates.summary.clone();
389
393
  data.ended_at = updates.ended_at.clone();
390
394
  data.branch = updates.branch.clone();
391
395
  data.commit = updates.commit.clone();
392
396
  data.dirty_files = updates.dirty_files.clone();
393
- data.decisions = updates.decisions.clone();
394
- data.blockers = updates.blockers.clone();
395
- data.checklist = updates.checklist.clone();
396
- data.handoff_notes = updates.handoff_notes.clone();
397
- data.references = updates.references.clone();
398
- data.context_pointers = updates.context_pointers.clone();
397
+
398
+ let was_provided = |key: &str| provided_keys.and_then(|v| v.get(key)).is_some();
399
+
400
+ if was_provided("decisions") {
401
+ data.decisions = updates.decisions.clone();
402
+ }
403
+ if was_provided("blockers") {
404
+ data.blockers = updates.blockers.clone();
405
+ }
406
+ if was_provided("checklist") {
407
+ data.checklist = updates.checklist.clone();
408
+ }
409
+ if was_provided("handoff_notes") {
410
+ data.handoff_notes = updates.handoff_notes.clone();
411
+ }
412
+ if was_provided("references") {
413
+ data.references = updates.references.clone();
414
+ }
415
+ if was_provided("context_pointers") {
416
+ data.context_pointers = updates.context_pointers.clone();
417
+ }
399
418
  if updates.environment.is_some() {
400
419
  data.environment = updates.environment.clone();
401
420
  }
@@ -405,7 +424,7 @@ fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
405
424
  if updates.label.is_some() {
406
425
  data.label = updates.label.clone();
407
426
  }
408
- if !updates.related_task_ids.is_empty() {
427
+ if was_provided("related_task_ids") && !updates.related_task_ids.is_empty() {
409
428
  data.related_task_ids = updates.related_task_ids.clone();
410
429
  }
411
430
  }
@@ -415,6 +434,7 @@ fn find_and_update_active_session(
415
434
  session_id: &str,
416
435
  updates: &SessionData,
417
436
  transition_to: Option<&str>,
437
+ provided_keys: Option<&Value>,
418
438
  ) -> Result<Option<PathBuf>> {
419
439
  let suffix = ".active.json";
420
440
 
@@ -464,7 +484,7 @@ fn find_and_update_active_session(
464
484
  let mut data: SessionData = serde_json::from_str(&content)
465
485
  .with_context(|| format!("Failed to parse session: {}", path.display()))?;
466
486
 
467
- apply_session_updates(&mut data, updates);
487
+ apply_session_updates(&mut data, updates, provided_keys);
468
488
 
469
489
  let updated_content =
470
490
  serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
@@ -495,16 +515,24 @@ pub fn update_and_close_active_session(
495
515
  sessions_dir: &Path,
496
516
  session_id: &str,
497
517
  updates: &SessionData,
518
+ provided_keys: Option<&Value>,
498
519
  ) -> Result<Option<PathBuf>> {
499
- find_and_update_active_session(sessions_dir, session_id, updates, Some("closed"))
520
+ find_and_update_active_session(
521
+ sessions_dir,
522
+ session_id,
523
+ updates,
524
+ Some("closed"),
525
+ provided_keys,
526
+ )
500
527
  }
501
528
 
502
529
  pub fn update_active_session(
503
530
  sessions_dir: &Path,
504
531
  session_id: &str,
505
532
  updates: &SessionData,
533
+ provided_keys: Option<&Value>,
506
534
  ) -> Result<Option<PathBuf>> {
507
- find_and_update_active_session(sessions_dir, session_id, updates, None)
535
+ find_and_update_active_session(sessions_dir, session_id, updates, None, provided_keys)
508
536
  }
509
537
 
510
538
  pub fn read_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<SessionData>> {