handoff-mcp-server 0.13.1 → 0.15.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/Cargo.lock +24 -229
- package/Cargo.toml +1 -1
- package/README.md +54 -0
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +17 -0
- package/src/cli.rs +551 -0
- package/src/lib.rs +1 -0
- package/src/main.rs +16 -6
- package/src/mcp/handlers/bulk_update.rs +1 -1
- package/src/mcp/handlers/check_criterion.rs +5 -6
- package/src/mcp/handlers/config.rs +38 -0
- package/src/mcp/handlers/get_task.rs +3 -6
- package/src/mcp/handlers/log_time.rs +3 -6
- package/src/mcp/handlers/mod.rs +4 -0
- package/src/mcp/handlers/timer.rs +529 -0
- package/src/mcp/handlers/update_task.rs +10 -21
- package/src/mcp/tools.rs +37 -0
- package/src/storage/config.rs +25 -0
- package/src/storage/tasks.rs +95 -4
package/src/cli.rs
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
//! CLI subcommand routing layer.
|
|
2
|
+
//!
|
|
3
|
+
//! Translates `handoff-mcp <group> <action> [--key value ...]` into a
|
|
4
|
+
//! `serde_json::Value` and delegates to the existing MCP handler. All output
|
|
5
|
+
//! goes to stdout as JSON for programmatic consumption.
|
|
6
|
+
|
|
7
|
+
use serde_json::{json, Value};
|
|
8
|
+
|
|
9
|
+
use crate::mcp::handlers;
|
|
10
|
+
|
|
11
|
+
/// Entry point called from `main()` when `args[1]` matches a known CLI group.
|
|
12
|
+
/// Returns the exit code (0 = success, 1 = error).
|
|
13
|
+
pub fn run(args: &[String]) -> i32 {
|
|
14
|
+
let result = dispatch(args);
|
|
15
|
+
match result {
|
|
16
|
+
Ok(output) => {
|
|
17
|
+
println!("{output}");
|
|
18
|
+
0
|
|
19
|
+
}
|
|
20
|
+
Err(e) => {
|
|
21
|
+
let err = json!({ "error": format!("{e:#}") });
|
|
22
|
+
println!(
|
|
23
|
+
"{}",
|
|
24
|
+
serde_json::to_string_pretty(&err).unwrap_or_else(|_| err.to_string())
|
|
25
|
+
);
|
|
26
|
+
1
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn dispatch(args: &[String]) -> anyhow::Result<String> {
|
|
32
|
+
let group = args.first().map(String::as_str).unwrap_or("");
|
|
33
|
+
let second = args.get(1).map(String::as_str).unwrap_or("");
|
|
34
|
+
|
|
35
|
+
// If the second arg is a flag (starts with --), treat it as no action.
|
|
36
|
+
let (action, flag_args) = if second.starts_with("--") || second.is_empty() {
|
|
37
|
+
("", if args.len() > 1 { &args[1..] } else { &[][..] })
|
|
38
|
+
} else {
|
|
39
|
+
(second, if args.len() > 2 { &args[2..] } else { &[][..] })
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// --help anywhere in the flags → show group help.
|
|
43
|
+
if flag_args.iter().any(|a| a == "--help" || a == "-h") {
|
|
44
|
+
print_group_help(group);
|
|
45
|
+
std::process::exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let tool_name = resolve_tool_name(group, action)?;
|
|
49
|
+
let arguments = parse_flags(flag_args, &tool_name)?;
|
|
50
|
+
|
|
51
|
+
// Delegate to the single dispatch table in handlers::handle_tool_call.
|
|
52
|
+
// It returns a JsonRpcResponse wrapping the result; we extract the text.
|
|
53
|
+
let response = handlers::handle_tool_call(&tool_name, &arguments);
|
|
54
|
+
extract_tool_result(response)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Extract the content text from a JsonRpcResponse returned by the MCP handler
|
|
58
|
+
/// dispatch. Surfaces handler-level errors as `Err` so the CLI prints them with
|
|
59
|
+
/// exit code 1.
|
|
60
|
+
fn extract_tool_result(response: crate::mcp::types::JsonRpcResponse) -> anyhow::Result<String> {
|
|
61
|
+
let result = response.result.ok_or_else(|| {
|
|
62
|
+
let msg = response
|
|
63
|
+
.error
|
|
64
|
+
.map(|e| e.message)
|
|
65
|
+
.unwrap_or_else(|| "Unknown error".to_string());
|
|
66
|
+
anyhow::anyhow!("{msg}")
|
|
67
|
+
})?;
|
|
68
|
+
|
|
69
|
+
if result
|
|
70
|
+
.get("isError")
|
|
71
|
+
.and_then(|v| v.as_bool())
|
|
72
|
+
.unwrap_or(false)
|
|
73
|
+
{
|
|
74
|
+
let text = result
|
|
75
|
+
.get("content")
|
|
76
|
+
.and_then(|c| c.as_array())
|
|
77
|
+
.and_then(|arr| arr.first())
|
|
78
|
+
.and_then(|item| item.get("text"))
|
|
79
|
+
.and_then(|t| t.as_str())
|
|
80
|
+
.unwrap_or("Unknown error");
|
|
81
|
+
anyhow::bail!("{text}");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let text = result
|
|
85
|
+
.get("content")
|
|
86
|
+
.and_then(|c| c.as_array())
|
|
87
|
+
.and_then(|arr| arr.first())
|
|
88
|
+
.and_then(|item| item.get("text"))
|
|
89
|
+
.and_then(|t| t.as_str())
|
|
90
|
+
.unwrap_or("")
|
|
91
|
+
.to_string();
|
|
92
|
+
|
|
93
|
+
Ok(text)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Map `(group, action)` to the internal MCP tool name.
|
|
97
|
+
fn resolve_tool_name(group: &str, action: &str) -> anyhow::Result<String> {
|
|
98
|
+
let name = match (group, action) {
|
|
99
|
+
// init (no action needed)
|
|
100
|
+
("init", "") => "handoff_init",
|
|
101
|
+
|
|
102
|
+
// task
|
|
103
|
+
("task", "list") => "handoff_list_tasks",
|
|
104
|
+
("task", "get") => "handoff_get_task",
|
|
105
|
+
("task", "update" | "create") => "handoff_update_task",
|
|
106
|
+
("task", "check") => "handoff_check_criterion",
|
|
107
|
+
("task", "bulk-update") => "handoff_bulk_update_tasks",
|
|
108
|
+
("task", "log-time") => "handoff_log_time",
|
|
109
|
+
("task", "import") => "handoff_import_context",
|
|
110
|
+
|
|
111
|
+
// session
|
|
112
|
+
("session", "load") => "handoff_load_context",
|
|
113
|
+
("session", "save") => "handoff_save_context",
|
|
114
|
+
("session", "list") => "handoff_list_sessions",
|
|
115
|
+
("session", "get") => "handoff_get_session",
|
|
116
|
+
("session", "update") => "handoff_update_session",
|
|
117
|
+
|
|
118
|
+
// config
|
|
119
|
+
("config", "get") => "handoff_get_config",
|
|
120
|
+
("config", "update") => "handoff_update_config",
|
|
121
|
+
|
|
122
|
+
// memory
|
|
123
|
+
("memory", "save") => "handoff_memory_save",
|
|
124
|
+
("memory", "query") => "handoff_memory_query",
|
|
125
|
+
("memory", "delete") => "handoff_memory_delete",
|
|
126
|
+
("memory", "cleanup") => "handoff_memory_cleanup",
|
|
127
|
+
|
|
128
|
+
// referral
|
|
129
|
+
("referral", "send" | "refer") => "handoff_refer",
|
|
130
|
+
("referral", "list") => "handoff_list_referrals",
|
|
131
|
+
("referral", "get") => "handoff_get_referral",
|
|
132
|
+
("referral", "update") => "handoff_update_referral",
|
|
133
|
+
|
|
134
|
+
// assignee
|
|
135
|
+
("assignee", "list") => "handoff_list_assignees",
|
|
136
|
+
("assignee", "add") => "handoff_add_assignee",
|
|
137
|
+
("assignee", "update") => "handoff_update_assignee",
|
|
138
|
+
("assignee", "remove") => "handoff_remove_assignee",
|
|
139
|
+
|
|
140
|
+
// milestone
|
|
141
|
+
("milestone", "list") => "handoff_list_milestones",
|
|
142
|
+
("milestone", "add") => "handoff_add_milestone",
|
|
143
|
+
("milestone", "update") => "handoff_update_milestone",
|
|
144
|
+
("milestone", "remove") => "handoff_remove_milestone",
|
|
145
|
+
|
|
146
|
+
// calendar / labels / project
|
|
147
|
+
("calendar", "update") => "handoff_update_calendar",
|
|
148
|
+
("labels", "update") => "handoff_update_labels",
|
|
149
|
+
("project", "start") => "handoff_start_project",
|
|
150
|
+
|
|
151
|
+
// metrics / capacity / schedule
|
|
152
|
+
("metrics", "" | "get") => "handoff_get_metrics",
|
|
153
|
+
("capacity", "" | "get") => "handoff_get_capacity",
|
|
154
|
+
("schedule", "" | "auto") => "handoff_auto_schedule",
|
|
155
|
+
|
|
156
|
+
// dashboard
|
|
157
|
+
("dashboard", "") => "handoff_dashboard",
|
|
158
|
+
|
|
159
|
+
// timer
|
|
160
|
+
("timer", "start") => "handoff_timer_start",
|
|
161
|
+
("timer", "stop") => "handoff_timer_stop",
|
|
162
|
+
("timer", "get") => "handoff_timer_get_time",
|
|
163
|
+
|
|
164
|
+
_ => {
|
|
165
|
+
if action.is_empty() {
|
|
166
|
+
anyhow::bail!(
|
|
167
|
+
"Unknown command: {group}\n\nRun `handoff-mcp --help` for available commands."
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
anyhow::bail!(
|
|
171
|
+
"Unknown command: {group} {action}\n\nRun `handoff-mcp {group} --help` for available actions."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
Ok(name.to_string())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Parse `--key value` flags into a JSON object.
|
|
180
|
+
///
|
|
181
|
+
/// Conventions:
|
|
182
|
+
/// - `--key value` → `{"key": "value"}` (string)
|
|
183
|
+
/// - `--key 42` → `{"key": 42}` (number if parseable)
|
|
184
|
+
/// - `--key true/false` → `{"key": true}` (bool)
|
|
185
|
+
/// - `--key a,b,c` → `{"key": ["a","b","c"]}` (comma-separated → array)
|
|
186
|
+
/// - `--json-key '{"a":1}'` → parsed as JSON value (for nested objects)
|
|
187
|
+
/// - `--flag` (no value or next arg starts with `--`) → `{"flag": true}`
|
|
188
|
+
///
|
|
189
|
+
/// Dashes in key names are converted to underscores (e.g. `--project-dir` →
|
|
190
|
+
/// `project_dir`) to match the MCP parameter naming.
|
|
191
|
+
fn parse_flags(args: &[String], tool_name: &str) -> anyhow::Result<Value> {
|
|
192
|
+
let mut map = serde_json::Map::new();
|
|
193
|
+
let mut i = 0;
|
|
194
|
+
|
|
195
|
+
while i < args.len() {
|
|
196
|
+
let arg = &args[i];
|
|
197
|
+
if !arg.starts_with("--") {
|
|
198
|
+
anyhow::bail!("Unexpected positional argument: {arg}");
|
|
199
|
+
}
|
|
200
|
+
let key = arg[2..].replace('-', "_");
|
|
201
|
+
|
|
202
|
+
let value = if i + 1 < args.len() && !args[i + 1].starts_with("--") {
|
|
203
|
+
i += 1;
|
|
204
|
+
parse_value(&args[i], &key)
|
|
205
|
+
} else {
|
|
206
|
+
Value::Bool(true)
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Nest into a `task` or `updates` object for tools that expect it.
|
|
210
|
+
insert_value(&mut map, &key, value, tool_name);
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
Ok(Value::Object(map))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// Insert a value into the correct position in the JSON object.
|
|
218
|
+
///
|
|
219
|
+
/// Some MCP tools expect nested objects (e.g. `handoff_update_task` wants
|
|
220
|
+
/// `{"task": {"id": ..., "title": ...}}`). This function routes known fields
|
|
221
|
+
/// into the right nesting level so the CLI user writes flat flags:
|
|
222
|
+
/// `--id t1 --title "foo"` instead of `--task '{"id":"t1","title":"foo"}'`.
|
|
223
|
+
fn insert_value(
|
|
224
|
+
map: &mut serde_json::Map<String, Value>,
|
|
225
|
+
key: &str,
|
|
226
|
+
value: Value,
|
|
227
|
+
tool_name: &str,
|
|
228
|
+
) {
|
|
229
|
+
match tool_name {
|
|
230
|
+
"handoff_update_task" => {
|
|
231
|
+
// Fields that go into the top-level `task` object.
|
|
232
|
+
let task_fields = [
|
|
233
|
+
"id",
|
|
234
|
+
"title",
|
|
235
|
+
"status",
|
|
236
|
+
"notes",
|
|
237
|
+
"priority",
|
|
238
|
+
"labels",
|
|
239
|
+
"links",
|
|
240
|
+
"done_criteria",
|
|
241
|
+
"assignee",
|
|
242
|
+
"dependencies",
|
|
243
|
+
"order",
|
|
244
|
+
];
|
|
245
|
+
// Fields that go into `task.schedule`.
|
|
246
|
+
let schedule_fields = [
|
|
247
|
+
"start_date",
|
|
248
|
+
"due_date",
|
|
249
|
+
"estimate_hours",
|
|
250
|
+
"actual_hours",
|
|
251
|
+
"remaining_hours",
|
|
252
|
+
"milestone",
|
|
253
|
+
"pinned",
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
if task_fields.contains(&key) {
|
|
257
|
+
let task = map
|
|
258
|
+
.entry("task")
|
|
259
|
+
.or_insert_with(|| json!({}))
|
|
260
|
+
.as_object_mut()
|
|
261
|
+
.expect("task must be object");
|
|
262
|
+
task.insert(key.to_string(), value);
|
|
263
|
+
} else if schedule_fields.contains(&key) {
|
|
264
|
+
let task = map
|
|
265
|
+
.entry("task")
|
|
266
|
+
.or_insert_with(|| json!({}))
|
|
267
|
+
.as_object_mut()
|
|
268
|
+
.expect("task must be object");
|
|
269
|
+
let schedule = task
|
|
270
|
+
.entry("schedule")
|
|
271
|
+
.or_insert_with(|| json!({}))
|
|
272
|
+
.as_object_mut()
|
|
273
|
+
.expect("schedule must be object");
|
|
274
|
+
schedule.insert(key.to_string(), value);
|
|
275
|
+
} else {
|
|
276
|
+
map.insert(key.to_string(), value);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
_ => {
|
|
280
|
+
map.insert(key.to_string(), value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Fields that are always strings even when they look like numbers. Handlers
|
|
286
|
+
/// call `.as_str()` on these, so coercing "42" to `Number(42)` would silently
|
|
287
|
+
/// break them.
|
|
288
|
+
const STRING_FIELDS: &[&str] = &[
|
|
289
|
+
"task_id",
|
|
290
|
+
"id",
|
|
291
|
+
"session_id",
|
|
292
|
+
"referral_id",
|
|
293
|
+
"key",
|
|
294
|
+
"name",
|
|
295
|
+
"text",
|
|
296
|
+
"kind",
|
|
297
|
+
"summary",
|
|
298
|
+
"status",
|
|
299
|
+
"status_filter",
|
|
300
|
+
"assignee_filter",
|
|
301
|
+
"milestone_filter",
|
|
302
|
+
"priority_filter",
|
|
303
|
+
"label_filter",
|
|
304
|
+
"assignee",
|
|
305
|
+
"priority",
|
|
306
|
+
"project_dir",
|
|
307
|
+
"project_name",
|
|
308
|
+
"description",
|
|
309
|
+
"notes",
|
|
310
|
+
"title",
|
|
311
|
+
"display_name",
|
|
312
|
+
"color",
|
|
313
|
+
"target_project",
|
|
314
|
+
"target_project_dir",
|
|
315
|
+
"referral_type",
|
|
316
|
+
"details",
|
|
317
|
+
"merge_into",
|
|
318
|
+
"tool_name",
|
|
319
|
+
"start_date",
|
|
320
|
+
"due_date",
|
|
321
|
+
"date",
|
|
322
|
+
"end_date",
|
|
323
|
+
"schedule_mode",
|
|
324
|
+
"session_status",
|
|
325
|
+
"close_session_id",
|
|
326
|
+
"pause_session_id",
|
|
327
|
+
"move_to",
|
|
328
|
+
"parent_id",
|
|
329
|
+
"milestone",
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
/// Fields that are always numeric. Only these are coerced from string to number.
|
|
333
|
+
const NUMERIC_FIELDS: &[&str] = &[
|
|
334
|
+
"hours",
|
|
335
|
+
"estimate_hours",
|
|
336
|
+
"actual_hours",
|
|
337
|
+
"remaining_hours",
|
|
338
|
+
"limit",
|
|
339
|
+
"criterion_index",
|
|
340
|
+
"order",
|
|
341
|
+
"work_hours_per_day",
|
|
342
|
+
"overwork_limit_percent",
|
|
343
|
+
"max_utilization",
|
|
344
|
+
"stale_days",
|
|
345
|
+
"checklist_index",
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
/// Parse a CLI flag value into a JSON type, using the field name to decide
|
|
349
|
+
/// whether numeric coercion is appropriate.
|
|
350
|
+
fn parse_value(s: &str, key: &str) -> Value {
|
|
351
|
+
// Try JSON parse first (handles objects, arrays, quoted strings).
|
|
352
|
+
if let Ok(v) = serde_json::from_str::<Value>(s) {
|
|
353
|
+
if v.is_object() || v.is_array() {
|
|
354
|
+
return v;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Known string fields — never coerce to number/bool.
|
|
359
|
+
if STRING_FIELDS.contains(&key) {
|
|
360
|
+
return Value::String(s.to_string());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Boolean.
|
|
364
|
+
if s == "true" {
|
|
365
|
+
return Value::Bool(true);
|
|
366
|
+
}
|
|
367
|
+
if s == "false" {
|
|
368
|
+
return Value::Bool(false);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Numeric coercion — only for known numeric fields.
|
|
372
|
+
if NUMERIC_FIELDS.contains(&key) {
|
|
373
|
+
if let Ok(n) = s.parse::<i64>() {
|
|
374
|
+
return Value::Number(n.into());
|
|
375
|
+
}
|
|
376
|
+
if let Ok(f) = s.parse::<f64>() {
|
|
377
|
+
if let Some(n) = serde_json::Number::from_f64(f) {
|
|
378
|
+
return Value::Number(n);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Comma-separated array (only if no spaces and contains comma).
|
|
384
|
+
if s.contains(',') && !s.contains(' ') {
|
|
385
|
+
let arr: Vec<Value> = s.split(',').map(|p| Value::String(p.to_string())).collect();
|
|
386
|
+
return Value::Array(arr);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
Value::String(s.to_string())
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// All known CLI groups (for help display).
|
|
393
|
+
pub const GROUPS: &[(&str, &str)] = &[
|
|
394
|
+
("init", "Initialize handoff tracking for a project"),
|
|
395
|
+
(
|
|
396
|
+
"task",
|
|
397
|
+
"Task management (list, get, update, check, bulk-update, log-time, import)",
|
|
398
|
+
),
|
|
399
|
+
(
|
|
400
|
+
"session",
|
|
401
|
+
"Session management (load, save, list, get, update)",
|
|
402
|
+
),
|
|
403
|
+
("config", "Configuration (get, update)"),
|
|
404
|
+
("memory", "Project memory (save, query, delete, cleanup)"),
|
|
405
|
+
(
|
|
406
|
+
"referral",
|
|
407
|
+
"Cross-project referrals (send, list, get, update)",
|
|
408
|
+
),
|
|
409
|
+
("assignee", "Team members (list, add, update, remove)"),
|
|
410
|
+
("milestone", "Milestones (list, add, update, remove)"),
|
|
411
|
+
("calendar", "Calendar settings (update)"),
|
|
412
|
+
("labels", "Project labels (update)"),
|
|
413
|
+
("project", "Project lifecycle (start)"),
|
|
414
|
+
("metrics", "Project metrics"),
|
|
415
|
+
("capacity", "Work capacity"),
|
|
416
|
+
("schedule", "Auto-scheduler"),
|
|
417
|
+
("dashboard", "Cross-project dashboard"),
|
|
418
|
+
("timer", "Timer coordination (start, stop, get)"),
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
pub fn print_cli_help() {
|
|
422
|
+
println!(
|
|
423
|
+
"handoff-mcp v{version} — CLI API
|
|
424
|
+
|
|
425
|
+
USAGE:
|
|
426
|
+
handoff-mcp <command> <action> [--key value ...]
|
|
427
|
+
|
|
428
|
+
COMMANDS:",
|
|
429
|
+
version = env!("CARGO_PKG_VERSION")
|
|
430
|
+
);
|
|
431
|
+
for (name, desc) in GROUPS {
|
|
432
|
+
println!(" {name:<16}{desc}");
|
|
433
|
+
}
|
|
434
|
+
println!(
|
|
435
|
+
"
|
|
436
|
+
GLOBAL OPTIONS:
|
|
437
|
+
--project-dir <path> Project directory (default: current directory)
|
|
438
|
+
--help Show help for a command
|
|
439
|
+
|
|
440
|
+
EXAMPLES:
|
|
441
|
+
handoff-mcp memory save --text \"Always use atomic_write\" --kind lesson
|
|
442
|
+
handoff-mcp memory query --text \"atomic\" --limit 5
|
|
443
|
+
handoff-mcp task list --status-filter todo
|
|
444
|
+
handoff-mcp session load
|
|
445
|
+
handoff-mcp metrics"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
pub fn print_group_help(group: &str) {
|
|
450
|
+
let actions: &[(&str, &str)] = match group {
|
|
451
|
+
"init" => &[("", "Initialize handoff tracking (--project-name <name>)")],
|
|
452
|
+
"task" => &[
|
|
453
|
+
("list", "List tasks (--status-filter, --assignee-filter, --milestone-filter, --priority-filter, --label-filter)"),
|
|
454
|
+
("get", "Get task detail (--task-id <id>)"),
|
|
455
|
+
("update", "Create or update a task (--id, --title, --status, --priority, --assignee, ...)"),
|
|
456
|
+
("check", "Toggle done_criteria item (--task-id, --criterion-index, --checked)"),
|
|
457
|
+
("bulk-update", "Bulk update tasks (--updates '[{...}]')"),
|
|
458
|
+
("log-time", "Log hours worked (--task-id, --hours)"),
|
|
459
|
+
("import", "Import context from document (--source '{...}')"),
|
|
460
|
+
],
|
|
461
|
+
"session" => &[
|
|
462
|
+
("load", "Load context at session start (--session-id)"),
|
|
463
|
+
("save", "Save context at session end (--summary, --session-status, ...)"),
|
|
464
|
+
("list", "List sessions (--status-filter, --limit)"),
|
|
465
|
+
("get", "Get session detail (--session-id)"),
|
|
466
|
+
("update", "Update active session (--add-decision '{...}', ...)"),
|
|
467
|
+
],
|
|
468
|
+
"config" => &[
|
|
469
|
+
("get", "Read project config"),
|
|
470
|
+
("update", "Update config (--updates '{...}')"),
|
|
471
|
+
],
|
|
472
|
+
"memory" => &[
|
|
473
|
+
("save", "Save a memory (--text, --kind, --tags, --scope-paths, --force)"),
|
|
474
|
+
("query", "Query memories (--text, --limit, --session-id)"),
|
|
475
|
+
("delete", "Delete a memory (--id)"),
|
|
476
|
+
("cleanup", "Housekeep memory store (--apply-exact-merges, --stale-days)"),
|
|
477
|
+
],
|
|
478
|
+
"referral" => &[
|
|
479
|
+
("send", "Send a referral to another project (--summary, --target-project, ...)"),
|
|
480
|
+
("list", "List incoming referrals (--status-filter)"),
|
|
481
|
+
("get", "Get referral detail (--referral-id)"),
|
|
482
|
+
("update", "Update referral status (--referral-id, --status)"),
|
|
483
|
+
],
|
|
484
|
+
"assignee" => &[
|
|
485
|
+
("list", "List team members"),
|
|
486
|
+
("add", "Add assignee (--key, --display-name, ...)"),
|
|
487
|
+
("update", "Update assignee (--key, --display-name, ...)"),
|
|
488
|
+
("remove", "Remove assignee (--key)"),
|
|
489
|
+
],
|
|
490
|
+
"milestone" => &[
|
|
491
|
+
("list", "List milestones"),
|
|
492
|
+
("add", "Add milestone (--name, --date, --description)"),
|
|
493
|
+
("update", "Update milestone (--name, --date, --description)"),
|
|
494
|
+
("remove", "Remove milestone (--name)"),
|
|
495
|
+
],
|
|
496
|
+
"calendar" => &[
|
|
497
|
+
("update", "Update calendar settings (--work-hours-per-day, --closed-weekdays, ...)"),
|
|
498
|
+
],
|
|
499
|
+
"labels" => &[
|
|
500
|
+
("update", "Set project labels (--labels a,b,c)"),
|
|
501
|
+
],
|
|
502
|
+
"project" => &[
|
|
503
|
+
("start", "Set project start date (--start-date, --shift-dates)"),
|
|
504
|
+
],
|
|
505
|
+
"metrics" => &[
|
|
506
|
+
("", "Get project metrics (--assignee)"),
|
|
507
|
+
],
|
|
508
|
+
"capacity" => &[
|
|
509
|
+
("", "Get work capacity (--start-date, --end-date, --assignee)"),
|
|
510
|
+
],
|
|
511
|
+
"schedule" => &[
|
|
512
|
+
("", "Run auto-scheduler (--dry-run, --assignee-filter, --start-date)"),
|
|
513
|
+
],
|
|
514
|
+
"dashboard" => &[
|
|
515
|
+
("", "Show cross-project dashboard (--scan-dirs, --include-completed)"),
|
|
516
|
+
],
|
|
517
|
+
"timer" => &[
|
|
518
|
+
("start", "Start timer for task (--task-id)"),
|
|
519
|
+
("stop", "Stop timer for task (--task-id)"),
|
|
520
|
+
("get", "Get timer state (--task-id)"),
|
|
521
|
+
],
|
|
522
|
+
_ => {
|
|
523
|
+
eprintln!("Unknown command group: {group}");
|
|
524
|
+
eprintln!("Run `handoff-mcp --help` for available commands.");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
let desc = GROUPS
|
|
530
|
+
.iter()
|
|
531
|
+
.find(|(n, _)| *n == group)
|
|
532
|
+
.map(|(_, d)| *d)
|
|
533
|
+
.unwrap_or("");
|
|
534
|
+
println!("handoff-mcp {group} — {desc}\n");
|
|
535
|
+
println!("ACTIONS:");
|
|
536
|
+
for (action, help) in actions {
|
|
537
|
+
if action.is_empty() {
|
|
538
|
+
println!(" (default) {help}");
|
|
539
|
+
} else {
|
|
540
|
+
println!(" {action:<16}{help}");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
println!(
|
|
544
|
+
"\nGLOBAL OPTIONS:\n --project-dir <path> Project directory (default: current directory)"
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/// Check if this group/action is a known CLI command.
|
|
549
|
+
pub fn is_cli_command(first_arg: &str) -> bool {
|
|
550
|
+
GROUPS.iter().any(|(name, _)| *name == first_arg)
|
|
551
|
+
}
|
package/src/lib.rs
CHANGED
package/src/main.rs
CHANGED
|
@@ -25,6 +25,16 @@ fn main() {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
other => {
|
|
28
|
+
if handoff_mcp::cli::is_cli_command(other) {
|
|
29
|
+
let cli_args: Vec<String> = args[1..].to_vec();
|
|
30
|
+
// Handle --help for group
|
|
31
|
+
if cli_args.len() >= 2 && (cli_args[1] == "--help" || cli_args[1] == "-h") {
|
|
32
|
+
handoff_mcp::cli::print_group_help(&cli_args[0]);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let code = handoff_mcp::cli::run(&cli_args);
|
|
36
|
+
std::process::exit(code);
|
|
37
|
+
}
|
|
28
38
|
eprintln!("Unknown command: {other}");
|
|
29
39
|
eprintln!("Run `handoff-mcp --help` for usage.");
|
|
30
40
|
std::process::exit(1);
|
|
@@ -58,19 +68,19 @@ fn main() {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
fn print_help() {
|
|
71
|
+
handoff_mcp::cli::print_cli_help();
|
|
61
72
|
println!(
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
USAGE:
|
|
73
|
+
"
|
|
74
|
+
SERVER MODE:
|
|
66
75
|
handoff-mcp Start the MCP server (stdio transport)
|
|
76
|
+
|
|
77
|
+
SETUP:
|
|
67
78
|
handoff-mcp setup Install memory auto-injection hooks into Claude Code
|
|
68
79
|
handoff-mcp setup --check Check if hooks are installed
|
|
69
80
|
handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
|
|
70
81
|
|
|
71
82
|
OPTIONS:
|
|
72
83
|
-h, --help Print this help message
|
|
73
|
-
-V, --version Print version"
|
|
74
|
-
version = env!("CARGO_PKG_VERSION")
|
|
84
|
+
-V, --version Print version"
|
|
75
85
|
);
|
|
76
86
|
}
|
|
@@ -45,7 +45,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
45
45
|
|
|
46
46
|
fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Value) -> Result<()> {
|
|
47
47
|
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
48
|
-
.ok_or_else(|| anyhow::anyhow!("
|
|
48
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
49
49
|
|
|
50
50
|
let (mut data, current_status) = read_task(&task_dir)?
|
|
51
51
|
.ok_or_else(|| anyhow::anyhow!("Task file not found for {task_id}"))?;
|
|
@@ -4,7 +4,9 @@ use serde_json::Value;
|
|
|
4
4
|
|
|
5
5
|
use super::resolve_project_dir;
|
|
6
6
|
use crate::storage::ensure_handoff_exists;
|
|
7
|
-
use crate::storage::tasks::{
|
|
7
|
+
use crate::storage::tasks::{
|
|
8
|
+
find_task_dir_by_id, find_task_file, read_task, suggest_task_id, write_task,
|
|
9
|
+
};
|
|
8
10
|
|
|
9
11
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
12
|
let project_dir = resolve_project_dir(arguments)?;
|
|
@@ -28,11 +30,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
28
30
|
.and_then(|v| v.as_bool())
|
|
29
31
|
.ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
|
|
30
32
|
|
|
31
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)
|
|
32
|
-
anyhow::anyhow!(
|
|
33
|
-
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
34
|
-
)
|
|
35
|
-
})?;
|
|
33
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
34
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
36
35
|
|
|
37
36
|
let (mut data, status) = read_task(&task_dir)?
|
|
38
37
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
@@ -53,6 +53,9 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
53
53
|
"settings.memory_query_limit",
|
|
54
54
|
"settings.memory_stale_days",
|
|
55
55
|
"settings.memory_injected_gc_days",
|
|
56
|
+
"settings.timer_provider",
|
|
57
|
+
"settings.timer_authority_ttl_secs",
|
|
58
|
+
"settings.timer_idle_timeout_minutes",
|
|
56
59
|
"dashboard.scan_dirs",
|
|
57
60
|
"dashboard.exclude_patterns",
|
|
58
61
|
"project.name",
|
|
@@ -179,6 +182,41 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
179
182
|
config.settings.memory_injected_gc_days = n;
|
|
180
183
|
applied.push(format!("settings.memory_injected_gc_days = {n}"));
|
|
181
184
|
}
|
|
185
|
+
"settings.timer_provider" => {
|
|
186
|
+
let Some(s) = value.as_str() else {
|
|
187
|
+
anyhow::bail!("settings.timer_provider must be a string");
|
|
188
|
+
};
|
|
189
|
+
let valid = ["auto", "vscode", "mcp", "off"];
|
|
190
|
+
if !valid.contains(&s) {
|
|
191
|
+
anyhow::bail!(
|
|
192
|
+
"settings.timer_provider must be one of: {}",
|
|
193
|
+
valid.join(", ")
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
config.settings.timer_provider = s.to_string();
|
|
197
|
+
applied.push(format!("settings.timer_provider = {s}"));
|
|
198
|
+
}
|
|
199
|
+
"settings.timer_authority_ttl_secs" => {
|
|
200
|
+
let Some(n) = value.as_u64() else {
|
|
201
|
+
anyhow::bail!(
|
|
202
|
+
"settings.timer_authority_ttl_secs must be a positive integer"
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
if n == 0 {
|
|
206
|
+
anyhow::bail!("settings.timer_authority_ttl_secs must be >= 1");
|
|
207
|
+
}
|
|
208
|
+
config.settings.timer_authority_ttl_secs = n;
|
|
209
|
+
applied.push(format!("settings.timer_authority_ttl_secs = {n}"));
|
|
210
|
+
}
|
|
211
|
+
"settings.timer_idle_timeout_minutes" => {
|
|
212
|
+
let Some(n) = value.as_u64() else {
|
|
213
|
+
anyhow::bail!(
|
|
214
|
+
"settings.timer_idle_timeout_minutes must be a non-negative integer"
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
config.settings.timer_idle_timeout_minutes = n;
|
|
218
|
+
applied.push(format!("settings.timer_idle_timeout_minutes = {n}"));
|
|
219
|
+
}
|
|
182
220
|
"dashboard.scan_dirs" => {
|
|
183
221
|
if let Some(arr) = value.as_array() {
|
|
184
222
|
config.dashboard.scan_dirs = arr
|