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.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- 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
|
-
}
|