handoff-mcp-server 0.13.0 → 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 +25 -229
- package/Cargo.toml +2 -2
- package/README.md +116 -18
- package/package.json +1 -1
- package/scripts/handoff-memory-hook.py +6 -6
- package/skills/handoff/SKILL.md +17 -0
- package/src/cli.rs +551 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +57 -0
- 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 +8 -4
- package/src/mcp/handlers/timer.rs +529 -0
- package/src/mcp/handlers/update_task.rs +10 -21
- package/src/mcp/tools.rs +41 -4
- package/src/setup.rs +424 -0
- package/src/storage/config.rs +25 -0
- package/src/storage/tasks.rs +95 -4
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
"""Claude Code hook wrapper for handoff-mcp project memory.
|
|
3
3
|
|
|
4
4
|
This is the **fallback path** for memory auto-injection. The preferred wiring is
|
|
5
|
-
a native ``mcp_tool`` hook that calls ``
|
|
5
|
+
a native ``mcp_tool`` hook that calls ``handoff_memory_query`` / ``handoff_memory_cleanup``
|
|
6
6
|
directly (see README "Project Memory"); this script exists for Claude Code
|
|
7
7
|
versions that lack the ``mcp_tool`` hook type, by translating a Claude Code hook
|
|
8
8
|
event into a single JSON-RPC ``tools/call`` against the handoff-mcp server and
|
|
9
9
|
emitting the result as ``hookSpecificOutput.additionalContext``.
|
|
10
10
|
|
|
11
11
|
It speaks the server's line-delimited JSON-RPC over stdio: one request object on
|
|
12
|
-
one line in, one response object on one line out. ``
|
|
13
|
-
``
|
|
12
|
+
one line in, one response object on one line out. ``handoff_memory_query`` /
|
|
13
|
+
``handoff_memory_cleanup`` return their payload as a JSON *string* inside
|
|
14
14
|
``result.content[0].text`` (so both this wrapper and the native hook path parse
|
|
15
15
|
it identically), which this script re-parses.
|
|
16
16
|
|
|
@@ -142,7 +142,7 @@ def _emit(event: str, context: str) -> None:
|
|
|
142
142
|
|
|
143
143
|
|
|
144
144
|
def _format_memories(payload: dict) -> str:
|
|
145
|
-
"""Turn a ``
|
|
145
|
+
"""Turn a ``handoff_memory_query`` payload into an injectable context block."""
|
|
146
146
|
memories = payload.get("memories") or []
|
|
147
147
|
if not memories:
|
|
148
148
|
return ""
|
|
@@ -188,7 +188,7 @@ def main() -> int:
|
|
|
188
188
|
if file_paths:
|
|
189
189
|
arguments["file_paths"] = file_paths
|
|
190
190
|
|
|
191
|
-
payload = _call(bin_path, "
|
|
191
|
+
payload = _call(bin_path, "handoff_memory_query", arguments)
|
|
192
192
|
if payload:
|
|
193
193
|
_emit(event, _format_memories(payload))
|
|
194
194
|
return 0
|
|
@@ -197,7 +197,7 @@ def main() -> int:
|
|
|
197
197
|
# Housekeeping only: merge exact duplicates and gc sidecars. We do not
|
|
198
198
|
# inject the cleanup recommendations as context (that is for an explicit
|
|
199
199
|
# AI-driven pass), so there is no additionalContext to emit here.
|
|
200
|
-
_call(bin_path, "
|
|
200
|
+
_call(bin_path, "handoff_memory_cleanup", {"project_dir": project_dir})
|
|
201
201
|
return 0
|
|
202
202
|
|
|
203
203
|
# Unknown event — nothing to do.
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -105,6 +105,23 @@ Use `handoff_log_time` to record hours worked on a task:
|
|
|
105
105
|
`schedule: { milestone: "v2" }` updates only the milestone and preserves
|
|
106
106
|
`actual_hours`/`remaining_hours`. It never replaces the whole schedule object.
|
|
107
107
|
|
|
108
|
+
### Timer Coordination (MCP ⇄ VSCode)
|
|
109
|
+
|
|
110
|
+
Use `handoff_timer_start` / `handoff_timer_stop` / `handoff_timer_get_time` to
|
|
111
|
+
track task time with automatic VSCode extension coordination:
|
|
112
|
+
|
|
113
|
+
- **`handoff_timer_start`** — if the VSCode extension is running (live authority
|
|
114
|
+
heartbeat), the request is delegated via `.handoff/timer/requests/`. If absent,
|
|
115
|
+
MCP starts a fallback internal timer.
|
|
116
|
+
- **`handoff_timer_stop`** — if delegated, creates a stop request for the extension.
|
|
117
|
+
If MCP is the fallback, stops the timer and atomically logs elapsed hours to
|
|
118
|
+
`actual_hours` (same as `handoff_log_time`).
|
|
119
|
+
- **`handoff_timer_get_time`** — reads `.handoff/timer/state.json` to show
|
|
120
|
+
elapsed time, state (tracking/paused/stopped), and current authority (vscode/mcp).
|
|
121
|
+
- The `timer_provider` config setting controls behavior: `"auto"` (default) uses
|
|
122
|
+
the authority protocol, `"vscode"` always delegates, `"mcp"` always uses
|
|
123
|
+
fallback, `"off"` disables timer tools entirely.
|
|
124
|
+
|
|
108
125
|
### Metrics & Project Health
|
|
109
126
|
|
|
110
127
|
Check project health with `handoff_get_metrics` at session start:
|
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
|
@@ -3,6 +3,45 @@ use std::io::{self, BufRead, Write};
|
|
|
3
3
|
use handoff_mcp::mcp::protocol::process_line;
|
|
4
4
|
|
|
5
5
|
fn main() {
|
|
6
|
+
let args: Vec<String> = std::env::args().collect();
|
|
7
|
+
|
|
8
|
+
if args.len() >= 2 {
|
|
9
|
+
match args[1].as_str() {
|
|
10
|
+
"setup" => {
|
|
11
|
+
let check = args.iter().any(|a| a == "--check");
|
|
12
|
+
let uninstall = args.iter().any(|a| a == "--uninstall");
|
|
13
|
+
if let Err(e) = handoff_mcp::setup::run_setup(check, uninstall) {
|
|
14
|
+
eprintln!("Error: {e:#}");
|
|
15
|
+
std::process::exit(1);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
"--version" | "-V" => {
|
|
20
|
+
println!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
"--help" | "-h" => {
|
|
24
|
+
print_help();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
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
|
+
}
|
|
38
|
+
eprintln!("Unknown command: {other}");
|
|
39
|
+
eprintln!("Run `handoff-mcp --help` for usage.");
|
|
40
|
+
std::process::exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
6
45
|
eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
7
46
|
|
|
8
47
|
let stdin = io::stdin();
|
|
@@ -27,3 +66,21 @@ fn main() {
|
|
|
27
66
|
}
|
|
28
67
|
}
|
|
29
68
|
}
|
|
69
|
+
|
|
70
|
+
fn print_help() {
|
|
71
|
+
handoff_mcp::cli::print_cli_help();
|
|
72
|
+
println!(
|
|
73
|
+
"
|
|
74
|
+
SERVER MODE:
|
|
75
|
+
handoff-mcp Start the MCP server (stdio transport)
|
|
76
|
+
|
|
77
|
+
SETUP:
|
|
78
|
+
handoff-mcp setup Install memory auto-injection hooks into Claude Code
|
|
79
|
+
handoff-mcp setup --check Check if hooks are installed
|
|
80
|
+
handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
|
|
81
|
+
|
|
82
|
+
OPTIONS:
|
|
83
|
+
-h, --help Print this help message
|
|
84
|
+
-V, --version Print version"
|
|
85
|
+
);
|
|
86
|
+
}
|