handoff-mcp-server 0.24.4 → 0.24.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +7 -7
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/main.rs +11 -7
- package/src/setup.rs +507 -80
package/Cargo.lock
CHANGED
|
@@ -25,9 +25,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
|
25
25
|
|
|
26
26
|
[[package]]
|
|
27
27
|
name = "bitflags"
|
|
28
|
-
version = "2.13.
|
|
28
|
+
version = "2.13.1"
|
|
29
29
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
30
|
-
checksum = "
|
|
30
|
+
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
|
31
31
|
|
|
32
32
|
[[package]]
|
|
33
33
|
name = "bumpalo"
|
|
@@ -145,7 +145,7 @@ dependencies = [
|
|
|
145
145
|
|
|
146
146
|
[[package]]
|
|
147
147
|
name = "handoff-mcp"
|
|
148
|
-
version = "0.24.
|
|
148
|
+
version = "0.24.5"
|
|
149
149
|
dependencies = [
|
|
150
150
|
"anyhow",
|
|
151
151
|
"chrono",
|
|
@@ -400,9 +400,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|
|
400
400
|
|
|
401
401
|
[[package]]
|
|
402
402
|
name = "syn"
|
|
403
|
-
version = "2.0.
|
|
403
|
+
version = "2.0.119"
|
|
404
404
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
405
|
-
checksum = "
|
|
405
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
406
406
|
dependencies = [
|
|
407
407
|
"proc-macro2",
|
|
408
408
|
"quote",
|
|
@@ -655,6 +655,6 @@ dependencies = [
|
|
|
655
655
|
|
|
656
656
|
[[package]]
|
|
657
657
|
name = "zmij"
|
|
658
|
-
version = "1.0.
|
|
658
|
+
version = "1.0.23"
|
|
659
659
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
660
|
-
checksum = "
|
|
660
|
+
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.5",
|
|
4
4
|
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
package/src/main.rs
CHANGED
|
@@ -31,9 +31,11 @@ fn main() {
|
|
|
31
31
|
let uninstall = args.iter().any(|a| a == "--uninstall");
|
|
32
32
|
let mcp_json = args.iter().any(|a| a == "--mcp-json");
|
|
33
33
|
let yes = args.iter().any(|a| a == "-y" || a == "--yes");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
let global = args.iter().any(|a| a == "--global");
|
|
35
|
+
let force = args.iter().any(|a| a == "--force");
|
|
36
|
+
if let Err(e) = handoff_mcp::setup::run_setup_with_opts(
|
|
37
|
+
check, uninstall, mcp_json, yes, global, force,
|
|
38
|
+
) {
|
|
37
39
|
eprintln!("Error: {e:#}");
|
|
38
40
|
std::process::exit(1);
|
|
39
41
|
}
|
|
@@ -140,10 +142,12 @@ SERVER MODE:
|
|
|
140
142
|
handoff-mcp Start the MCP server (stdio transport)
|
|
141
143
|
|
|
142
144
|
SETUP:
|
|
143
|
-
handoff-mcp setup Install hooks +
|
|
144
|
-
handoff-mcp setup --
|
|
145
|
-
handoff-mcp setup --
|
|
146
|
-
handoff-mcp setup --
|
|
145
|
+
handoff-mcp setup Install hooks + MCP server + CLAUDE.md template
|
|
146
|
+
handoff-mcp setup --global Use ~/.claude/settings.json mcpServers (not .mcp.json)
|
|
147
|
+
handoff-mcp setup --check Check if hooks, MCP, and CLAUDE.md are configured
|
|
148
|
+
handoff-mcp setup --uninstall Remove handoff hooks and MCP entry
|
|
149
|
+
handoff-mcp setup --mcp-json Add handoff MCP server entry only (non-interactive)
|
|
150
|
+
handoff-mcp setup --force Replace existing CLAUDE.md handoff section with latest
|
|
147
151
|
handoff-mcp setup -y Skip all confirmation prompts
|
|
148
152
|
|
|
149
153
|
OPTIONS:
|
package/src/setup.rs
CHANGED
|
@@ -6,6 +6,9 @@ use serde_json::Value;
|
|
|
6
6
|
|
|
7
7
|
const HOOK_SERVER: &str = "handoff";
|
|
8
8
|
const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
|
|
9
|
+
const CLAUDE_MD_MARKER: &str = "## Session Handoff";
|
|
10
|
+
|
|
11
|
+
const CLAUDE_MD_TEMPLATE: &str = include_str!("../templates/claude-md-section.md");
|
|
9
12
|
|
|
10
13
|
fn settings_path() -> Result<PathBuf> {
|
|
11
14
|
let home = std::env::var("HOME")
|
|
@@ -19,6 +22,68 @@ fn mcp_json_path() -> PathBuf {
|
|
|
19
22
|
cwd.join(".mcp.json")
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
fn claude_md_path() -> PathBuf {
|
|
26
|
+
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
27
|
+
cwd.join("CLAUDE.md")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn has_claude_md_handoff_section(path: &Path) -> bool {
|
|
31
|
+
let Ok(text) = std::fs::read_to_string(path) else {
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
text.contains(CLAUDE_MD_MARKER)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn ensure_claude_md_section(path: &Path, force: bool) -> Result<bool> {
|
|
38
|
+
let has_section = path.exists() && has_claude_md_handoff_section(path);
|
|
39
|
+
|
|
40
|
+
if has_section && !force {
|
|
41
|
+
return Ok(false);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let mut content = if path.exists() {
|
|
45
|
+
std::fs::read_to_string(path)
|
|
46
|
+
.with_context(|| format!("failed to read {}", path.display()))?
|
|
47
|
+
} else {
|
|
48
|
+
String::from("# CLAUDE.md\n")
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if has_section {
|
|
52
|
+
content = replace_claude_md_section(&content);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if !content.ends_with('\n') {
|
|
56
|
+
content.push('\n');
|
|
57
|
+
}
|
|
58
|
+
content.push('\n');
|
|
59
|
+
content.push_str(CLAUDE_MD_TEMPLATE);
|
|
60
|
+
|
|
61
|
+
std::fs::write(path, &content)
|
|
62
|
+
.with_context(|| format!("failed to write {}", path.display()))?;
|
|
63
|
+
Ok(true)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn replace_claude_md_section(text: &str) -> String {
|
|
67
|
+
let Some(start) = text.find(CLAUDE_MD_MARKER) else {
|
|
68
|
+
return text.to_string();
|
|
69
|
+
};
|
|
70
|
+
let after_marker = start + CLAUDE_MD_MARKER.len();
|
|
71
|
+
let end = text[after_marker..]
|
|
72
|
+
.find("\n## ")
|
|
73
|
+
.map(|pos| after_marker + pos)
|
|
74
|
+
.unwrap_or(text.len());
|
|
75
|
+
let mut result = text[..start].trim_end_matches('\n').to_string();
|
|
76
|
+
let trailing = text[end..].trim_start_matches('\n');
|
|
77
|
+
if !trailing.is_empty() {
|
|
78
|
+
result.push_str("\n\n");
|
|
79
|
+
result.push_str(trailing);
|
|
80
|
+
}
|
|
81
|
+
if !result.ends_with('\n') {
|
|
82
|
+
result.push('\n');
|
|
83
|
+
}
|
|
84
|
+
result
|
|
85
|
+
}
|
|
86
|
+
|
|
22
87
|
fn build_mcp_server_entry() -> Value {
|
|
23
88
|
serde_json::json!({
|
|
24
89
|
"type": "stdio",
|
|
@@ -58,6 +123,47 @@ fn ensure_mcp_json_entry(path: &Path) -> Result<bool> {
|
|
|
58
123
|
Ok(true)
|
|
59
124
|
}
|
|
60
125
|
|
|
126
|
+
fn ensure_global_mcp_entry(settings: &mut Value) -> Result<bool> {
|
|
127
|
+
let obj = settings
|
|
128
|
+
.as_object_mut()
|
|
129
|
+
.context("settings.json root is not an object")?;
|
|
130
|
+
let servers = obj
|
|
131
|
+
.entry("mcpServers")
|
|
132
|
+
.or_insert_with(|| Value::Object(serde_json::Map::new()))
|
|
133
|
+
.as_object_mut()
|
|
134
|
+
.context("settings.json 'mcpServers' is not an object")?;
|
|
135
|
+
|
|
136
|
+
if servers.contains_key(HOOK_SERVER) {
|
|
137
|
+
return Ok(false);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
servers.insert(HOOK_SERVER.to_string(), build_mcp_server_entry());
|
|
141
|
+
Ok(true)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn has_global_mcp_entry(settings: &Value) -> bool {
|
|
145
|
+
settings
|
|
146
|
+
.get("mcpServers")
|
|
147
|
+
.and_then(|s| s.get(HOOK_SERVER))
|
|
148
|
+
.is_some()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fn remove_global_mcp_entry(settings: &mut Value) -> bool {
|
|
152
|
+
let Some(servers) = settings
|
|
153
|
+
.get_mut("mcpServers")
|
|
154
|
+
.and_then(|v| v.as_object_mut())
|
|
155
|
+
else {
|
|
156
|
+
return false;
|
|
157
|
+
};
|
|
158
|
+
let removed = servers.remove(HOOK_SERVER).is_some();
|
|
159
|
+
if removed && servers.is_empty() {
|
|
160
|
+
if let Some(obj) = settings.as_object_mut() {
|
|
161
|
+
obj.remove("mcpServers");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
removed
|
|
165
|
+
}
|
|
166
|
+
|
|
61
167
|
fn has_mcp_json_entry(path: &Path) -> bool {
|
|
62
168
|
if !path.exists() {
|
|
63
169
|
return false;
|
|
@@ -204,7 +310,7 @@ fn strip_legacy_session_start_hook(hooks_obj: &mut serde_json::Map<String, Value
|
|
|
204
310
|
}
|
|
205
311
|
|
|
206
312
|
pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
|
|
207
|
-
run_setup_with_opts(check_only, uninstall, false, false)
|
|
313
|
+
run_setup_with_opts(check_only, uninstall, false, false, false, false)
|
|
208
314
|
}
|
|
209
315
|
|
|
210
316
|
pub fn run_setup_with_opts(
|
|
@@ -212,6 +318,8 @@ pub fn run_setup_with_opts(
|
|
|
212
318
|
uninstall: bool,
|
|
213
319
|
mcp_json: bool,
|
|
214
320
|
yes: bool,
|
|
321
|
+
global: bool,
|
|
322
|
+
force: bool,
|
|
215
323
|
) -> Result<()> {
|
|
216
324
|
anyhow::ensure!(
|
|
217
325
|
!(check_only && uninstall),
|
|
@@ -222,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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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!(" .
|
|
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!("\
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
541
|
+
if !global {
|
|
542
|
+
let mcp_path = mcp_json_path();
|
|
543
|
+
let needs_mcp = !has_mcp_json_entry(&mcp_path);
|
|
382
544
|
|
|
383
|
-
|
|
384
|
-
|
|
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(
|
|
584
|
+
prompt_yn(prompt_msg)
|
|
389
585
|
};
|
|
390
586
|
|
|
391
587
|
if should_write {
|
|
392
|
-
let
|
|
588
|
+
let existed = claude_md.exists();
|
|
589
|
+
let added = ensure_claude_md_section(&claude_md, force)?;
|
|
393
590
|
if added {
|
|
394
|
-
|
|
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
|
|
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
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
648
|
+
if removed > 0 {
|
|
649
|
+
anything_removed = true;
|
|
650
|
+
println!("{removed} hook event(s) cleaned up.");
|
|
448
651
|
}
|
|
449
652
|
}
|
|
450
653
|
|
|
451
|
-
if
|
|
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
|
}
|