handoff-mcp-server 0.26.0 → 0.28.0

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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
package/src/setup.rs DELETED
@@ -1,1212 +0,0 @@
1
- use std::collections::BTreeMap;
2
- use std::path::{Path, PathBuf};
3
-
4
- use anyhow::{Context, Result};
5
- use serde_json::Value;
6
-
7
- const HOOK_SERVER: &str = "handoff";
8
- const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
9
- const CLAUDE_MD_MARKER: &str = "## Session Handoff";
10
-
11
- const CLAUDE_MD_TEMPLATE: &str = include_str!("../templates/claude-md-section.md");
12
-
13
- fn settings_path() -> Result<PathBuf> {
14
- let home = std::env::var("HOME")
15
- .or_else(|_| std::env::var("USERPROFILE"))
16
- .context("cannot determine home directory (HOME / USERPROFILE not set)")?;
17
- Ok(Path::new(&home).join(".claude").join("settings.json"))
18
- }
19
-
20
- fn mcp_json_path() -> PathBuf {
21
- let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
22
- cwd.join(".mcp.json")
23
- }
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
-
87
- fn build_mcp_server_entry() -> Value {
88
- serde_json::json!({
89
- "type": "stdio",
90
- "command": "handoff-mcp",
91
- "args": [],
92
- "env": {}
93
- })
94
- }
95
-
96
- fn ensure_mcp_json_entry(path: &Path) -> Result<bool> {
97
- let mut root = if path.exists() {
98
- let text = std::fs::read_to_string(path)
99
- .with_context(|| format!("failed to read {}", path.display()))?;
100
- serde_json::from_str::<Value>(&text)
101
- .with_context(|| format!("failed to parse {}", path.display()))?
102
- } else {
103
- serde_json::json!({ "mcpServers": {} })
104
- };
105
-
106
- let servers = root
107
- .as_object_mut()
108
- .context(".mcp.json root is not an object")?
109
- .entry("mcpServers")
110
- .or_insert_with(|| Value::Object(serde_json::Map::new()))
111
- .as_object_mut()
112
- .context(".mcp.json 'mcpServers' is not an object")?;
113
-
114
- if servers.contains_key(HOOK_SERVER) {
115
- return Ok(false);
116
- }
117
-
118
- servers.insert(HOOK_SERVER.to_string(), build_mcp_server_entry());
119
-
120
- let text = serde_json::to_string_pretty(&root)?;
121
- std::fs::write(path, text + "\n")
122
- .with_context(|| format!("failed to write {}", path.display()))?;
123
- Ok(true)
124
- }
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
-
167
- fn has_mcp_json_entry(path: &Path) -> bool {
168
- if !path.exists() {
169
- return false;
170
- }
171
- let Ok(text) = std::fs::read_to_string(path) else {
172
- return false;
173
- };
174
- let Ok(root) = serde_json::from_str::<Value>(&text) else {
175
- return false;
176
- };
177
- root.get("mcpServers")
178
- .and_then(|s| s.get(HOOK_SERVER))
179
- .is_some()
180
- }
181
-
182
- fn read_settings(path: &Path) -> Result<Value> {
183
- if path.exists() {
184
- let text = std::fs::read_to_string(path)
185
- .with_context(|| format!("failed to read {}", path.display()))?;
186
- serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
187
- } else {
188
- Ok(Value::Object(serde_json::Map::new()))
189
- }
190
- }
191
-
192
- fn write_settings(path: &Path, value: &Value) -> Result<()> {
193
- let parent = path
194
- .parent()
195
- .context("settings path has no parent directory")?;
196
- std::fs::create_dir_all(parent)
197
- .with_context(|| format!("failed to create {}", parent.display()))?;
198
-
199
- let text = serde_json::to_string_pretty(value)?;
200
- let tmp = parent.join(".settings.json.tmp");
201
- std::fs::write(&tmp, text + "\n")
202
- .with_context(|| format!("failed to write {}", tmp.display()))?;
203
- std::fs::rename(&tmp, path)
204
- .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
205
- }
206
-
207
- fn mcp_tool_hook(tool: &str, input: Value) -> Value {
208
- serde_json::json!({
209
- "type": "mcp_tool",
210
- "server": HOOK_SERVER,
211
- "tool": tool,
212
- "input": input
213
- })
214
- }
215
-
216
- fn build_hooks_config() -> BTreeMap<&'static str, Value> {
217
- let mut hooks = BTreeMap::new();
218
-
219
- hooks.insert(
220
- "UserPromptSubmit",
221
- serde_json::json!([{
222
- "hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
223
- "project_dir": "${CLAUDE_PROJECT_DIR}",
224
- "session_id": "${session_id}",
225
- "text": "${prompt}"
226
- }))]
227
- }]),
228
- );
229
-
230
- hooks.insert(
231
- "PreToolUse",
232
- serde_json::json!([{
233
- "matcher": "Edit|Write|MultiEdit",
234
- "hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
235
- "project_dir": "${CLAUDE_PROJECT_DIR}",
236
- "session_id": "${session_id}",
237
- "tool_name": "${tool_name}",
238
- "text": "${tool_input.file_path}",
239
- "file_paths": ["${tool_input.file_path}"]
240
- }))]
241
- }]),
242
- );
243
-
244
- hooks
245
- }
246
-
247
- fn has_handoff_hook(arr: &Value) -> bool {
248
- let Some(entries) = arr.as_array() else {
249
- return false;
250
- };
251
- for entry in entries {
252
- let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
253
- continue;
254
- };
255
- for hook in hooks {
256
- if hook.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER) {
257
- return true;
258
- }
259
- }
260
- }
261
- false
262
- }
263
-
264
- /// Legacy event that used to carry a synchronous `handoff_memory_cleanup`
265
- /// hook. Removed as a desired hook in this fix (see wiki/100-stdio-concurrency.md)
266
- /// because it was the trigger for the VSCode hang under many parallel
267
- /// sub-agents. Still used to detect and migrate away stale installs from
268
- /// before this fix.
269
- const LEGACY_SESSION_START_EVENT: &str = "SessionStart";
270
-
271
- /// True if `settings` still has a handoff-owned hook registered under the
272
- /// legacy `SessionStart` event (i.e. an install performed before this fix
273
- /// removed the synchronous cleanup hook). Used both to auto-migrate on
274
- /// `setup` re-run and to flag the issue in `setup --check`.
275
- fn has_legacy_session_start_hook(settings: &Value) -> bool {
276
- settings
277
- .get("hooks")
278
- .and_then(|h| h.get(LEGACY_SESSION_START_EVENT))
279
- .map(has_handoff_hook)
280
- .unwrap_or(false)
281
- }
282
-
283
- /// Removes handoff-owned hook entries from the legacy `SessionStart` event,
284
- /// leaving any other (non-handoff) hooks registered under that event intact.
285
- /// Returns true if anything was removed.
286
- fn strip_legacy_session_start_hook(hooks_obj: &mut serde_json::Map<String, Value>) -> bool {
287
- let Some(arr) = hooks_obj
288
- .get_mut(LEGACY_SESSION_START_EVENT)
289
- .and_then(|v| v.as_array_mut())
290
- else {
291
- return false;
292
- };
293
-
294
- let before = arr.len();
295
- arr.retain(|entry| {
296
- let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
297
- return true;
298
- };
299
- !hooks
300
- .iter()
301
- .any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
302
- });
303
- let removed = arr.len() != before;
304
-
305
- if arr.is_empty() {
306
- hooks_obj.remove(LEGACY_SESSION_START_EVENT);
307
- }
308
-
309
- removed
310
- }
311
-
312
- pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
313
- run_setup_with_opts(check_only, uninstall, false, false, false, false)
314
- }
315
-
316
- pub fn run_setup_with_opts(
317
- check_only: bool,
318
- uninstall: bool,
319
- mcp_json: bool,
320
- yes: bool,
321
- global: bool,
322
- force: bool,
323
- ) -> Result<()> {
324
- anyhow::ensure!(
325
- !(check_only && uninstall),
326
- "--check and --uninstall cannot be used together"
327
- );
328
-
329
- let path = settings_path()?;
330
- let mut settings = read_settings(&path)?;
331
-
332
- if check_only {
333
- return run_check(&settings, &path, global);
334
- }
335
-
336
- if uninstall {
337
- return run_uninstall(&mut settings, &path, global);
338
- }
339
-
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)
345
- }
346
-
347
- fn run_check(settings: &Value, path: &Path, global: bool) -> Result<()> {
348
- println!("Settings file: {}", path.display());
349
-
350
- let hooks_obj = settings.get("hooks");
351
- let desired = build_hooks_config();
352
- let mut all_ok = true;
353
-
354
- for event in desired.keys() {
355
- let installed = hooks_obj
356
- .and_then(|h| h.get(*event))
357
- .map(has_handoff_hook)
358
- .unwrap_or(false);
359
-
360
- if installed {
361
- println!(" {event}: installed");
362
- } else {
363
- println!(" {event}: NOT installed");
364
- all_ok = false;
365
- }
366
- }
367
-
368
- if has_legacy_session_start_hook(settings) {
369
- println!(
370
- " {LEGACY_SESSION_START_EVENT}: legacy handoff_memory_cleanup hook found (removed in this version)"
371
- );
372
- println!(
373
- "\nWARNING: a synchronous SessionStart cleanup hook from an older install was \
374
- found. This is the known trigger for VSCode hangs under many parallel \
375
- sub-agents. Run `handoff-mcp setup` (without --check) to remove it."
376
- );
377
- all_ok = false;
378
- }
379
-
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");
405
- all_ok = false;
406
- } else {
407
- println!(" CLAUDE.md: not found");
408
- all_ok = false;
409
- }
410
-
411
- if all_ok {
412
- println!("\nAll hooks are configured. Memory auto-injection is active.");
413
- } else {
414
- println!("\nSome items need attention. Run `handoff-mcp setup` to install/migrate them.");
415
- }
416
-
417
- Ok(())
418
- }
419
-
420
- fn prompt_yn(question: &str) -> bool {
421
- use std::io::{self, Write};
422
- print!("{question} [Y/n] ");
423
- io::stdout().flush().ok();
424
- let mut input = String::new();
425
- if io::stdin().read_line(&mut input).is_err() {
426
- return true;
427
- }
428
- let trimmed = input.trim().to_lowercase();
429
- trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
430
- }
431
-
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<()> {
466
- let obj = settings
467
- .as_object_mut()
468
- .context("settings.json root is not an object")?;
469
-
470
- let hooks_val = obj
471
- .entry("hooks")
472
- .or_insert_with(|| Value::Object(serde_json::Map::new()));
473
- let hooks_obj = hooks_val
474
- .as_object_mut()
475
- .context("settings.json 'hooks' is not an object")?;
476
-
477
- let desired = build_hooks_config();
478
- let mut installed = 0u32;
479
- let mut skipped = 0u32;
480
-
481
- for (event, config) in desired {
482
- let existing = hooks_obj.get(event);
483
- if existing.map(has_handoff_hook).unwrap_or(false) {
484
- println!(" {event}: already installed, skipping");
485
- skipped += 1;
486
- continue;
487
- }
488
-
489
- match existing {
490
- Some(Value::Array(arr)) => {
491
- let mut merged = arr.clone();
492
- if let Some(new_entries) = config.as_array() {
493
- merged.extend(new_entries.iter().cloned());
494
- }
495
- hooks_obj.insert(event.to_string(), Value::Array(merged));
496
- println!(" {event}: merged with existing hooks");
497
- }
498
- _ => {
499
- hooks_obj.insert(event.to_string(), config);
500
- println!(" {event}: installed");
501
- }
502
- }
503
- installed += 1;
504
- }
505
-
506
- let migrated = strip_legacy_session_start_hook(hooks_obj);
507
- if migrated {
508
- println!(
509
- " {LEGACY_SESSION_START_EVENT}: removed legacy handoff_memory_cleanup hook \
510
- (known VSCode hang trigger)"
511
- );
512
- }
513
-
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 {
529
- write_settings(path, settings)?;
530
- println!("\nWrote {path}", path = path.display());
531
- if installed > 0 {
532
- println!("{installed} hook(s) installed, {skipped} already present.");
533
- }
534
- if migrated {
535
- println!("Legacy SessionStart cleanup hook removed. See README for migration details.");
536
- }
537
- } else {
538
- println!("\nAll hooks already installed.");
539
- }
540
-
541
- if !global {
542
- let mcp_path = mcp_json_path();
543
- let needs_mcp = !has_mcp_json_entry(&mcp_path);
544
-
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 {
581
- true
582
- } else {
583
- println!();
584
- prompt_yn(prompt_msg)
585
- };
586
-
587
- if should_write {
588
- let existed = claude_md.exists();
589
- let added = ensure_claude_md_section(&claude_md, force)?;
590
- if added {
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}");
599
- }
600
- } else {
601
- println!(" CLAUDE.md: skipped");
602
- }
603
- }
604
-
605
- println!("\nRestart Claude Code for changes to take effect.");
606
- Ok(())
607
- }
608
-
609
- fn run_uninstall(settings: &mut Value, path: &Path, global: bool) -> Result<()> {
610
- let mut anything_removed = false;
611
-
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;
615
-
616
- for event in &events {
617
- let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
618
- continue;
619
- };
620
-
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
- }
646
- }
647
-
648
- if removed > 0 {
649
- anything_removed = true;
650
- println!("{removed} hook event(s) cleaned up.");
651
- }
652
- }
653
-
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 {
660
- write_settings(path, settings)?;
661
- println!("\nWrote {path}", path = path.display());
662
- println!("\nRestart Claude Code for changes to take effect.");
663
- } else {
664
- println!("No handoff hooks found. Nothing to remove.");
665
- }
666
-
667
- Ok(())
668
- }
669
-
670
- #[cfg(test)]
671
- mod tests {
672
- use super::*;
673
-
674
- #[test]
675
- fn build_hooks_has_two_events() {
676
- let hooks = build_hooks_config();
677
- assert_eq!(hooks.len(), 2);
678
- assert!(hooks.contains_key("UserPromptSubmit"));
679
- assert!(hooks.contains_key("PreToolUse"));
680
- assert!(!hooks.contains_key("SessionStart"));
681
- }
682
-
683
- #[test]
684
- fn has_handoff_hook_detects_presence() {
685
- let arr = serde_json::json!([{
686
- "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
687
- }]);
688
- assert!(has_handoff_hook(&arr));
689
- }
690
-
691
- #[test]
692
- fn has_handoff_hook_returns_false_for_other_server() {
693
- let arr = serde_json::json!([{
694
- "hooks": [{"type": "mcp_tool", "server": "other", "tool": "other_tool"}]
695
- }]);
696
- assert!(!has_handoff_hook(&arr));
697
- }
698
-
699
- #[test]
700
- fn has_handoff_hook_returns_false_for_empty() {
701
- assert!(!has_handoff_hook(&serde_json::json!([])));
702
- }
703
-
704
- #[test]
705
- fn install_into_empty_settings() {
706
- let dir = tempfile::tempdir().unwrap();
707
- let path = dir.path().join("settings.json");
708
-
709
- let mut settings = Value::Object(serde_json::Map::new());
710
- run_install(&mut settings, &path, false, false, false).unwrap();
711
-
712
- let written: Value =
713
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
714
- let hooks = written.get("hooks").unwrap().as_object().unwrap();
715
- assert!(hooks.contains_key("UserPromptSubmit"));
716
- assert!(hooks.contains_key("PreToolUse"));
717
- assert!(!hooks.contains_key("SessionStart"));
718
- }
719
-
720
- #[test]
721
- fn install_merges_with_existing_hooks() {
722
- let dir = tempfile::tempdir().unwrap();
723
- let path = dir.path().join("settings.json");
724
-
725
- let mut settings = serde_json::json!({
726
- "hooks": {
727
- "UserPromptSubmit": [{
728
- "hooks": [{"type": "command", "command": "my-other-hook"}]
729
- }]
730
- }
731
- });
732
-
733
- run_install(&mut settings, &path, false, false, false).unwrap();
734
-
735
- let written: Value =
736
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
737
- let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
738
- assert_eq!(user_prompt.len(), 2);
739
- assert!(has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
740
- }
741
-
742
- #[test]
743
- fn install_skips_if_already_present() {
744
- let dir = tempfile::tempdir().unwrap();
745
- let path = dir.path().join("settings.json");
746
-
747
- let mut settings = Value::Object(serde_json::Map::new());
748
- run_install(&mut settings, &path, false, false, false).unwrap();
749
-
750
- let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
751
- run_install(&mut settings2, &path, false, false, false).unwrap();
752
-
753
- let written: Value =
754
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
755
- let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
756
- assert_eq!(user_prompt.len(), 1);
757
- assert!(!written["hooks"]
758
- .as_object()
759
- .unwrap()
760
- .contains_key("SessionStart"));
761
- }
762
-
763
- #[test]
764
- fn uninstall_removes_handoff_hooks() {
765
- let dir = tempfile::tempdir().unwrap();
766
- let path = dir.path().join("settings.json");
767
-
768
- let mut settings = Value::Object(serde_json::Map::new());
769
- run_install(&mut settings, &path, false, false, false).unwrap();
770
-
771
- let mut settings2: Value =
772
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
773
- run_uninstall(&mut settings2, &path, false).unwrap();
774
-
775
- let written: Value =
776
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
777
- assert!(written.get("hooks").is_none());
778
- }
779
-
780
- #[test]
781
- fn check_and_uninstall_conflict_is_rejected() {
782
- assert!(run_setup(true, true).is_err());
783
- }
784
-
785
- #[test]
786
- fn install_preserves_key_order() {
787
- let dir = tempfile::tempdir().unwrap();
788
- let path = dir.path().join("settings.json");
789
-
790
- let original = r#"{
791
- "env": {"A": "1", "B": "2"},
792
- "model": "opus",
793
- "permissions": {"defaultMode": "auto"}
794
- }
795
- "#;
796
- std::fs::write(&path, original).unwrap();
797
-
798
- let mut settings: Value = serde_json::from_str(original).unwrap();
799
- run_install(&mut settings, &path, false, false, false).unwrap();
800
-
801
- let written = std::fs::read_to_string(&path).unwrap();
802
- let env_pos = written.find("\"env\"").unwrap();
803
- let hooks_pos = written.find("\"hooks\"").unwrap();
804
- let model_pos = written.find("\"model\"").unwrap();
805
- assert!(env_pos < model_pos, "env should come before model");
806
- assert!(
807
- hooks_pos > env_pos,
808
- "hooks should be appended after existing keys"
809
- );
810
- }
811
-
812
- #[test]
813
- fn install_removes_legacy_session_start_cleanup_hook() {
814
- let dir = tempfile::tempdir().unwrap();
815
- let path = dir.path().join("settings.json");
816
-
817
- // Simulate a settings.json written by a pre-fix version of
818
- // `handoff-mcp setup`, which still installed a SessionStart
819
- // cleanup hook (the very hang trigger this task removes).
820
- let mut settings = serde_json::json!({
821
- "hooks": {
822
- "SessionStart": [{
823
- "hooks": [{
824
- "type": "mcp_tool",
825
- "server": "handoff",
826
- "tool": "handoff_memory_cleanup",
827
- "input": {"project_dir": "${CLAUDE_PROJECT_DIR}"}
828
- }]
829
- }],
830
- "UserPromptSubmit": [{
831
- "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
832
- }],
833
- "PreToolUse": [{
834
- "matcher": "Edit|Write|MultiEdit",
835
- "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
836
- }]
837
- }
838
- });
839
-
840
- run_install(&mut settings, &path, false, false, false).unwrap();
841
-
842
- let written: Value =
843
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
844
- let hooks = written.get("hooks").unwrap().as_object().unwrap();
845
- assert!(
846
- !hooks.contains_key("SessionStart"),
847
- "legacy SessionStart cleanup hook must be removed by re-running setup"
848
- );
849
- assert!(hooks.contains_key("UserPromptSubmit"));
850
- assert!(hooks.contains_key("PreToolUse"));
851
- }
852
-
853
- #[test]
854
- fn install_preserves_non_handoff_session_start_hooks() {
855
- let dir = tempfile::tempdir().unwrap();
856
- let path = dir.path().join("settings.json");
857
-
858
- let mut settings = serde_json::json!({
859
- "hooks": {
860
- "SessionStart": [
861
- {"hooks": [{"type": "command", "command": "my-other-hook"}]},
862
- {"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup"}]}
863
- ]
864
- }
865
- });
866
-
867
- run_install(&mut settings, &path, false, false, false).unwrap();
868
-
869
- let written: Value =
870
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
871
- let session_start = written["hooks"]["SessionStart"].as_array().unwrap();
872
- assert_eq!(session_start.len(), 1);
873
- assert!(!has_handoff_hook(&written["hooks"]["SessionStart"]));
874
- }
875
-
876
- #[test]
877
- fn check_flags_legacy_session_start_cleanup_hook() {
878
- let settings = serde_json::json!({
879
- "hooks": {
880
- "SessionStart": [{
881
- "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup"}]
882
- }]
883
- }
884
- });
885
-
886
- assert!(has_legacy_session_start_hook(&settings));
887
- }
888
-
889
- #[test]
890
- fn check_does_not_flag_when_no_legacy_hook_present() {
891
- let settings = serde_json::json!({
892
- "hooks": {
893
- "UserPromptSubmit": [{
894
- "hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
895
- }]
896
- }
897
- });
898
-
899
- assert!(!has_legacy_session_start_hook(&settings));
900
- }
901
-
902
- #[test]
903
- fn uninstall_preserves_other_hooks() {
904
- let dir = tempfile::tempdir().unwrap();
905
- let path = dir.path().join("settings.json");
906
-
907
- let mut settings = serde_json::json!({
908
- "hooks": {
909
- "UserPromptSubmit": [
910
- {"hooks": [{"type": "command", "command": "my-hook"}]},
911
- {"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]}
912
- ]
913
- }
914
- });
915
- run_uninstall(&mut settings, &path, false).unwrap();
916
-
917
- let written: Value =
918
- serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
919
- let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
920
- assert_eq!(user_prompt.len(), 1);
921
- assert!(!has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
922
- }
923
-
924
- #[test]
925
- fn ensure_mcp_json_creates_file_when_absent() {
926
- let dir = tempfile::tempdir().unwrap();
927
- let path = dir.path().join(".mcp.json");
928
- assert!(!path.exists());
929
-
930
- let added = ensure_mcp_json_entry(&path).unwrap();
931
- assert!(added);
932
- assert!(path.exists());
933
-
934
- let root: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
935
- assert!(root["mcpServers"]["handoff"]["command"]
936
- .as_str()
937
- .unwrap()
938
- .contains("handoff-mcp"));
939
- }
940
-
941
- #[test]
942
- fn ensure_mcp_json_adds_to_existing_file() {
943
- let dir = tempfile::tempdir().unwrap();
944
- let path = dir.path().join(".mcp.json");
945
- std::fs::write(
946
- &path,
947
- r#"{"mcpServers":{"context7":{"type":"stdio","command":"npx"}}}"#,
948
- )
949
- .unwrap();
950
-
951
- let added = ensure_mcp_json_entry(&path).unwrap();
952
- assert!(added);
953
-
954
- let root: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
955
- assert!(root["mcpServers"].get("context7").is_some());
956
- assert!(root["mcpServers"].get("handoff").is_some());
957
- }
958
-
959
- #[test]
960
- fn ensure_mcp_json_skips_when_already_present() {
961
- let dir = tempfile::tempdir().unwrap();
962
- let path = dir.path().join(".mcp.json");
963
- std::fs::write(
964
- &path,
965
- r#"{"mcpServers":{"handoff":{"type":"stdio","command":"handoff-mcp"}}}"#,
966
- )
967
- .unwrap();
968
-
969
- let added = ensure_mcp_json_entry(&path).unwrap();
970
- assert!(!added);
971
- }
972
-
973
- #[test]
974
- fn has_mcp_json_entry_checks_correctly() {
975
- let dir = tempfile::tempdir().unwrap();
976
-
977
- let absent = dir.path().join("absent.json");
978
- assert!(!has_mcp_json_entry(&absent));
979
-
980
- let present = dir.path().join("present.json");
981
- std::fs::write(&present, r#"{"mcpServers":{"handoff":{"type":"stdio"}}}"#).unwrap();
982
- assert!(has_mcp_json_entry(&present));
983
-
984
- let no_handoff = dir.path().join("other.json");
985
- std::fs::write(
986
- &no_handoff,
987
- r#"{"mcpServers":{"context7":{"type":"stdio"}}}"#,
988
- )
989
- .unwrap();
990
- assert!(!has_mcp_json_entry(&no_handoff));
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
- }
1212
- }