handoff-mcp-server 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/skills/handoff/agents/openai.yaml +12 -0
  6. package/skills/handoff-docs/agents/openai.yaml +12 -0
  7. package/skills/handoff-import/agents/openai.yaml +12 -0
  8. package/skills/handoff-load/agents/openai.yaml +12 -0
  9. package/skills/handoff-memory/agents/openai.yaml +12 -0
  10. package/skills/handoff-refer/agents/openai.yaml +12 -0
  11. package/Cargo.lock +0 -686
  12. package/Cargo.toml +0 -29
  13. package/scripts/cargo-env.sh +0 -29
  14. package/scripts/handoff-memory-hook.py +0 -208
  15. package/scripts/install-local.sh +0 -109
  16. package/scripts/postinstall.js +0 -50
  17. package/scripts/sync-plugin-skills.sh +0 -35
  18. package/scripts/sync-plugin-version.sh +0 -85
  19. package/scripts/sync-workflow-inline.sh +0 -138
  20. package/src/cli.rs +0 -551
  21. package/src/context/injection.rs +0 -276
  22. package/src/context/mod.rs +0 -129
  23. package/src/lib.rs +0 -5
  24. package/src/main.rs +0 -157
  25. package/src/mcp/handlers/assignees.rs +0 -254
  26. package/src/mcp/handlers/auto_schedule.rs +0 -489
  27. package/src/mcp/handlers/bulk_update.rs +0 -155
  28. package/src/mcp/handlers/calendar.rs +0 -196
  29. package/src/mcp/handlers/capacity.rs +0 -318
  30. package/src/mcp/handlers/check_criterion.rs +0 -70
  31. package/src/mcp/handlers/config.rs +0 -402
  32. package/src/mcp/handlers/config_crud.rs +0 -183
  33. package/src/mcp/handlers/dashboard.rs +0 -214
  34. package/src/mcp/handlers/docs.rs +0 -2288
  35. package/src/mcp/handlers/docs_query.rs +0 -1335
  36. package/src/mcp/handlers/fork_session.rs +0 -91
  37. package/src/mcp/handlers/get_session.rs +0 -48
  38. package/src/mcp/handlers/get_task.rs +0 -53
  39. package/src/mcp/handlers/import_context.rs +0 -470
  40. package/src/mcp/handlers/init.rs +0 -28
  41. package/src/mcp/handlers/list_sessions.rs +0 -187
  42. package/src/mcp/handlers/list_tasks.rs +0 -308
  43. package/src/mcp/handlers/load_context.rs +0 -361
  44. package/src/mcp/handlers/log_time.rs +0 -67
  45. package/src/mcp/handlers/memory.rs +0 -961
  46. package/src/mcp/handlers/merge_sessions.rs +0 -103
  47. package/src/mcp/handlers/metrics.rs +0 -196
  48. package/src/mcp/handlers/milestones.rs +0 -102
  49. package/src/mcp/handlers/mod.rs +0 -140
  50. package/src/mcp/handlers/refer.rs +0 -307
  51. package/src/mcp/handlers/referrals.rs +0 -74
  52. package/src/mcp/handlers/save_context.rs +0 -354
  53. package/src/mcp/handlers/task_checklist.rs +0 -507
  54. package/src/mcp/handlers/timer.rs +0 -529
  55. package/src/mcp/handlers/update_session.rs +0 -197
  56. package/src/mcp/handlers/update_task.rs +0 -452
  57. package/src/mcp/mod.rs +0 -6
  58. package/src/mcp/protocol.rs +0 -41
  59. package/src/mcp/resources.rs +0 -57
  60. package/src/mcp/router.rs +0 -154
  61. package/src/mcp/tools.rs +0 -1522
  62. package/src/mcp/types.rs +0 -108
  63. package/src/setup.rs +0 -1212
  64. package/src/storage/config.rs +0 -578
  65. package/src/storage/docs/frontmatter.rs +0 -509
  66. package/src/storage/docs/mod.rs +0 -835
  67. package/src/storage/docs/model.rs +0 -708
  68. package/src/storage/docs/reassemble.rs +0 -167
  69. package/src/storage/docs/split.rs +0 -377
  70. package/src/storage/git.rs +0 -47
  71. package/src/storage/memory/injected.rs +0 -340
  72. package/src/storage/memory/mod.rs +0 -236
  73. package/src/storage/memory/model.rs +0 -127
  74. package/src/storage/mod.rs +0 -96
  75. package/src/storage/referrals.rs +0 -248
  76. package/src/storage/sessions.rs +0 -859
  77. package/src/storage/tasks.rs +0 -957
  78. package/templates/claude-md-section.md +0 -12
package/src/cli.rs DELETED
@@ -1,551 +0,0 @@
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
- }