delegate-agent-cli 0.11.0__py3-none-any.whl

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 (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1627 @@
1
+ """Command-line argument parsing.
2
+
3
+ Turns ``argv`` into a typed ``ParsedCommand`` (which subcommand, global options,
4
+ launch options, and per-subcommand argument objects). This layer only parses and
5
+ validates argument shape; workspace/prompt resolution and request construction
6
+ live in ``request_build``. The SAFE-mode security strictness lives in the argv
7
+ builders, not here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import re
14
+ import shlex
15
+ from typing import NoReturn
16
+
17
+ from delegate_agent import (
18
+ capability_commands,
19
+ command_help,
20
+ config_commands,
21
+ inspection_commands,
22
+ profile_commands,
23
+ reasoning,
24
+ run_output_commands,
25
+ wait_cancel_commands,
26
+ worktree_commands,
27
+ worktree_mgmt,
28
+ )
29
+ from delegate_agent import config as delegate_config
30
+ from delegate_agent.constants import (
31
+ ENGINES_PROSE,
32
+ KNOWN_ENGINES,
33
+ MODELESS_ENGINES,
34
+ validate_mode,
35
+ )
36
+ from delegate_agent.errors import DelegateError
37
+ from delegate_agent.request_models import (
38
+ GlobalOptions,
39
+ InspectionOptions,
40
+ LaunchOptions,
41
+ ParsedCommand,
42
+ RunJsonOptions,
43
+ )
44
+ from delegate_agent.run_output_commands import RUN_OUTPUT_DEFAULT_TAIL_LINES
45
+
46
+ MISPLACED_GLOBAL_OPTIONS = frozenset(
47
+ {
48
+ "--json",
49
+ "--cwd",
50
+ "--isolation",
51
+ "--pass-through",
52
+ "--completion-report",
53
+ "--no-completion-report",
54
+ "--auth-profile",
55
+ "--group",
56
+ }
57
+ )
58
+
59
+ VALUE_GLOBAL_OPTIONS = frozenset(
60
+ {"--cwd", "--isolation", "--completion-report", "--auth-profile", "--group"}
61
+ )
62
+
63
+ AUTH_PROFILE_SUBCOMMANDS = frozenset(KNOWN_ENGINES) | frozenset(
64
+ {"dry-run", "run", "profiles", "capabilities"}
65
+ )
66
+ GROUP_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
67
+ GROUP_SUBCOMMANDS = frozenset(KNOWN_ENGINES) | frozenset({"dry-run", "run"})
68
+
69
+
70
+ def validate_group(value: str, *, option: str = "--group") -> str:
71
+ if not GROUP_RE.fullmatch(value):
72
+ raise DelegateError(
73
+ "invalid_group",
74
+ f"{option} must match [A-Za-z0-9._-]{{1,64}}.",
75
+ )
76
+ return value
77
+
78
+
79
+ def infer_global_json(argv: list[str]) -> bool:
80
+ i = 0
81
+ while i < len(argv):
82
+ token = argv[i]
83
+ if token == "--json":
84
+ return True
85
+ if token in VALUE_GLOBAL_OPTIONS:
86
+ i += 2
87
+ continue
88
+ if token in MISPLACED_GLOBAL_OPTIONS:
89
+ i += 1
90
+ continue
91
+ if token in ("--help", "-h", "--version"):
92
+ return False
93
+ break
94
+ return False
95
+
96
+
97
+ def canonical_help_topic(path: str | None) -> str | None:
98
+ """Resolve a detected command path to its nearest valid COMMAND_SPECS key.
99
+
100
+ Returns the path unchanged when it is empty/None (overview) or an exact spec
101
+ key. Otherwise it falls back to the nearest valid prefix (e.g. an unknown
102
+ worktree action maps to ``worktree``); a leftover with no spec is returned
103
+ as-is so emit_command_help can surface unknown_help_topic.
104
+ """
105
+
106
+ if not path:
107
+ return path
108
+ if path in command_help.COMMAND_SPECS:
109
+ return path
110
+ parts = path.split()
111
+ while len(parts) > 1:
112
+ parts.pop()
113
+ candidate = " ".join(parts)
114
+ if candidate in command_help.COMMAND_SPECS:
115
+ return candidate
116
+ return path
117
+
118
+
119
+ def help_command(json_mode: bool, topic: str | None) -> ParsedCommand:
120
+ """Build a help ParsedCommand, mapping topic to a canonical spec key."""
121
+
122
+ return ParsedCommand(
123
+ "help",
124
+ global_options=GlobalOptions(json_mode=json_mode),
125
+ help_topic=canonical_help_topic(topic),
126
+ )
127
+
128
+
129
+ def parse_help_subcommand(rest: list[str], json_mode: bool) -> ParsedCommand:
130
+ """Parse ``help [<command> [<subcommand>]]`` (D3).
131
+
132
+ ``--json`` and help tokens are honored from anywhere in ``rest``; remaining
133
+ tokens name the topic. An empty topic yields the overview, unless a help
134
+ token was present, in which case ``delegate help --help`` describes the help
135
+ command itself.
136
+ """
137
+
138
+ help_requested = False
139
+ topic_parts: list[str] = []
140
+ for token in rest:
141
+ if token == "--json":
142
+ json_mode = True
143
+ continue
144
+ if command_help.is_help_token(token):
145
+ help_requested = True
146
+ continue
147
+ topic_parts.append(token)
148
+
149
+ if topic_parts:
150
+ topic = " ".join(topic_parts)
151
+ elif help_requested:
152
+ topic = "help"
153
+ else:
154
+ topic = None
155
+ return help_command(json_mode, topic)
156
+
157
+
158
+ SIMPLE_INSPECTION_SUBCOMMANDS = frozenset({"models", "describe", "agent-help"})
159
+ INSPECTION_OPTION_SUBCOMMANDS = frozenset({"models", "describe"})
160
+
161
+
162
+ def parse_simple_inspection_subcommand(
163
+ name: str,
164
+ rest: list[str],
165
+ *,
166
+ json_mode: bool,
167
+ cwd: str | None,
168
+ pass_through: bool,
169
+ completion_report: str | None,
170
+ isolation: str | None,
171
+ auth_profile: str | None,
172
+ ) -> ParsedCommand:
173
+ rest, json_mode = consume_json_option(rest, json_mode)
174
+ if any(command_help.is_help_token(token) for token in rest):
175
+ return help_command(json_mode, name)
176
+ summary = False
177
+ if name in INSPECTION_OPTION_SUBCOMMANDS:
178
+ for token in rest:
179
+ if token == "--summary":
180
+ summary = True
181
+ continue
182
+ require_no_extra([token], name)
183
+ else:
184
+ require_no_extra(rest, name)
185
+ return ParsedCommand(
186
+ name,
187
+ global_options=GlobalOptions(
188
+ json_mode=json_mode,
189
+ cwd=cwd,
190
+ pass_through=pass_through,
191
+ completion_report=completion_report,
192
+ isolation=isolation,
193
+ auth_profile=auth_profile,
194
+ ),
195
+ inspection=InspectionOptions(summary=summary),
196
+ )
197
+
198
+
199
+ def parse_capabilities_subcommand(
200
+ rest: list[str],
201
+ *,
202
+ json_mode: bool,
203
+ cwd: str | None,
204
+ pass_through: bool,
205
+ completion_report: str | None,
206
+ isolation: str | None,
207
+ auth_profile: str | None,
208
+ ) -> ParsedCommand:
209
+ rest, json_mode = consume_json_option(rest, json_mode)
210
+ if any(command_help.is_help_token(token) for token in rest):
211
+ return help_command(json_mode, "capabilities")
212
+ refresh = False
213
+ if rest == ["refresh"]:
214
+ refresh = True
215
+ elif rest:
216
+ require_no_extra(rest, "capabilities")
217
+ if auth_profile is not None and not refresh:
218
+ raise DelegateError(
219
+ "invalid_option_combination",
220
+ "--auth-profile is only supported with capabilities refresh; "
221
+ "the cached capabilities report does not spawn a harness.",
222
+ )
223
+ return ParsedCommand(
224
+ "capabilities",
225
+ global_options=GlobalOptions(
226
+ json_mode=json_mode,
227
+ cwd=cwd,
228
+ pass_through=pass_through,
229
+ completion_report=completion_report,
230
+ isolation=isolation,
231
+ auth_profile=auth_profile,
232
+ ),
233
+ capabilities=capability_commands.CapabilitiesCommand(
234
+ refresh=refresh,
235
+ json_mode=json_mode,
236
+ ),
237
+ )
238
+
239
+
240
+ def parse_config_subcommand(
241
+ rest: list[str],
242
+ *,
243
+ json_mode: bool,
244
+ cwd: str | None,
245
+ pass_through: bool,
246
+ completion_report: str | None,
247
+ isolation: str | None,
248
+ auth_profile: str | None,
249
+ ) -> ParsedCommand:
250
+ rest, json_mode = consume_json_option(rest, json_mode)
251
+ if not rest or command_help.is_help_token(rest[0]):
252
+ return help_command(json_mode, "config")
253
+ if cwd is not None or pass_through or completion_report is not None or isolation is not None:
254
+ raise DelegateError(
255
+ "invalid_option_combination",
256
+ "delegate config does not use --cwd, --isolation, --pass-through, or completion-report options.",
257
+ )
258
+ if auth_profile is not None:
259
+ raise DelegateError(
260
+ "invalid_option_combination",
261
+ "--auth-profile is not supported with delegate config.",
262
+ )
263
+ action = rest[0]
264
+ if action not in {"init", "sync-profiles"}:
265
+ raise DelegateError("unknown_config_action", f"Unknown config action: {action}")
266
+ force = False
267
+ if action == "sync-profiles":
268
+ for token in rest[1:]:
269
+ if command_help.is_help_token(token):
270
+ return help_command(json_mode, "config sync-profiles")
271
+ require_no_extra([token], "config sync-profiles")
272
+ return ParsedCommand(
273
+ "config",
274
+ global_options=GlobalOptions(json_mode=json_mode),
275
+ config_command=config_commands.ConfigCommand(
276
+ action="sync-profiles",
277
+ json_mode=json_mode,
278
+ ),
279
+ )
280
+ for token in rest[1:]:
281
+ if command_help.is_help_token(token):
282
+ return help_command(json_mode, "config init")
283
+ if token == "--force":
284
+ force = True
285
+ continue
286
+ require_no_extra([token], "config init")
287
+ return ParsedCommand(
288
+ "config",
289
+ global_options=GlobalOptions(json_mode=json_mode),
290
+ config_command=config_commands.ConfigCommand(
291
+ action="init",
292
+ force=force,
293
+ json_mode=json_mode,
294
+ ),
295
+ )
296
+
297
+
298
+ def parse_cli(argv: list[str]) -> ParsedCommand:
299
+ if not argv or argv[0] in ("--help", "-h"):
300
+ return ParsedCommand("help")
301
+ if argv[0] == "--version":
302
+ return ParsedCommand("version")
303
+
304
+ json_mode = False
305
+ cwd: str | None = None
306
+ pass_through = False
307
+ completion_report: str | None = None
308
+ isolation: str | None = None
309
+ auth_profile: str | None = None
310
+ group: str | None = None
311
+ i = 0
312
+ while i < len(argv):
313
+ token = argv[i]
314
+ if token == "--json":
315
+ json_mode = True
316
+ i += 1
317
+ continue
318
+ if token == "--cwd":
319
+ if i + 1 >= len(argv):
320
+ raise DelegateError("missing_cwd", "--cwd requires a path.")
321
+ cwd = argv[i + 1]
322
+ i += 2
323
+ continue
324
+ if token == "--pass-through":
325
+ pass_through = True
326
+ i += 1
327
+ continue
328
+ if token == "--no-completion-report":
329
+ completion_report = delegate_config.COMPLETION_REPORT_MODE_NONE
330
+ i += 1
331
+ continue
332
+ if token == "--completion-report":
333
+ if i + 1 >= len(argv):
334
+ raise DelegateError(
335
+ "missing_completion_report", "--completion-report requires markdown or none."
336
+ )
337
+ completion_report = argv[i + 1]
338
+ if completion_report not in delegate_config.COMPLETION_REPORT_MODES:
339
+ raise DelegateError(
340
+ "invalid_completion_report",
341
+ "--completion-report must be markdown or none.",
342
+ )
343
+ i += 2
344
+ continue
345
+ if token == "--isolation":
346
+ if i + 1 >= len(argv):
347
+ raise DelegateError("missing_isolation_value", "--isolation requires a value.")
348
+ isolation = argv[i + 1]
349
+ if isolation not in delegate_config.VALID_ISOLATION_VALUES:
350
+ raise DelegateError(
351
+ "invalid_isolation",
352
+ "--isolation must be auto, none, or worktree.",
353
+ )
354
+ i += 2
355
+ continue
356
+ if token == "--auth-profile":
357
+ if i + 1 >= len(argv):
358
+ raise DelegateError(
359
+ "missing_auth_profile", "--auth-profile requires a profile name."
360
+ )
361
+ auth_profile = argv[i + 1]
362
+ if not auth_profile or auth_profile.startswith("-"):
363
+ raise DelegateError(
364
+ "missing_auth_profile", "--auth-profile requires a profile name."
365
+ )
366
+ i += 2
367
+ continue
368
+ if token == "--group":
369
+ if i + 1 >= len(argv):
370
+ raise DelegateError("missing_group", "--group requires a group name.")
371
+ group = validate_group(argv[i + 1])
372
+ i += 2
373
+ continue
374
+ break
375
+
376
+ if json_mode and pass_through:
377
+ raise DelegateError(
378
+ "invalid_option_combination",
379
+ "--pass-through is incompatible with --json.",
380
+ )
381
+
382
+ if i >= len(argv):
383
+ raise DelegateError("missing_subcommand", "Missing subcommand.")
384
+
385
+ subcommand = argv[i]
386
+ if subcommand == "list":
387
+ subcommand = "runs"
388
+ rest = argv[i + 1 :]
389
+ if subcommand.startswith("-"):
390
+ raise DelegateError(
391
+ "unknown_option", f"Unknown global option before subcommand: {subcommand}"
392
+ )
393
+ if auth_profile is not None and subcommand not in AUTH_PROFILE_SUBCOMMANDS:
394
+ raise DelegateError(
395
+ "invalid_option_combination",
396
+ f"--auth-profile is not supported with delegate {subcommand}; "
397
+ "use it with launches, dry-run, run --input-json, profiles, or capabilities refresh.",
398
+ )
399
+ if group is not None and subcommand not in GROUP_SUBCOMMANDS:
400
+ raise DelegateError(
401
+ "invalid_option_combination",
402
+ f"--group is not supported with delegate {subcommand}; use command-specific "
403
+ "--group selectors where available.",
404
+ )
405
+
406
+ if subcommand == "help":
407
+ return parse_help_subcommand(rest, json_mode)
408
+
409
+ if subcommand in SIMPLE_INSPECTION_SUBCOMMANDS:
410
+ return parse_simple_inspection_subcommand(
411
+ subcommand,
412
+ rest,
413
+ json_mode=json_mode,
414
+ cwd=cwd,
415
+ pass_through=pass_through,
416
+ completion_report=completion_report,
417
+ isolation=isolation,
418
+ auth_profile=auth_profile,
419
+ )
420
+ if subcommand == "capabilities":
421
+ return parse_capabilities_subcommand(
422
+ rest,
423
+ json_mode=json_mode,
424
+ cwd=cwd,
425
+ pass_through=pass_through,
426
+ completion_report=completion_report,
427
+ isolation=isolation,
428
+ auth_profile=auth_profile,
429
+ )
430
+ if subcommand == "config":
431
+ return parse_config_subcommand(
432
+ rest,
433
+ json_mode=json_mode,
434
+ cwd=cwd,
435
+ pass_through=pass_through,
436
+ completion_report=completion_report,
437
+ isolation=isolation,
438
+ auth_profile=auth_profile,
439
+ )
440
+ if subcommand == "run":
441
+ return parse_run(
442
+ rest, json_mode, cwd, pass_through, completion_report, isolation, auth_profile, group
443
+ )
444
+ if subcommand in MODELESS_ENGINES:
445
+ return parse_modeless_engine(
446
+ subcommand,
447
+ rest,
448
+ json_mode,
449
+ cwd,
450
+ dry_run=False,
451
+ pass_through=pass_through,
452
+ completion_report=completion_report,
453
+ isolation=isolation,
454
+ auth_profile=auth_profile,
455
+ group=group,
456
+ )
457
+ if subcommand == "droid":
458
+ return parse_droid(
459
+ rest,
460
+ json_mode,
461
+ cwd,
462
+ dry_run=False,
463
+ pass_through=pass_through,
464
+ completion_report=completion_report,
465
+ isolation=isolation,
466
+ auth_profile=auth_profile,
467
+ )
468
+ if subcommand == "dry-run":
469
+ return parse_dry_run(
470
+ rest, json_mode, cwd, pass_through, completion_report, isolation, auth_profile, group
471
+ )
472
+ if subcommand == "snapshot":
473
+ return parse_snapshot(rest, json_mode, cwd)
474
+ if subcommand == "runs":
475
+ return parse_runs(rest, json_mode, cwd)
476
+ if subcommand == "run-output":
477
+ return parse_run_output(rest, json_mode, cwd)
478
+ if subcommand == "wait":
479
+ if isolation is not None:
480
+ raise DelegateError(
481
+ "invalid_option_combination",
482
+ "--isolation is not supported with delegate wait.",
483
+ )
484
+ return parse_wait(rest, json_mode, cwd)
485
+ if subcommand == "cancel":
486
+ if isolation is not None:
487
+ raise DelegateError(
488
+ "invalid_option_combination",
489
+ "--isolation is not supported with delegate cancel.",
490
+ )
491
+ return parse_cancel(rest, json_mode, cwd)
492
+ if subcommand == "worktree":
493
+ if isolation is not None:
494
+ raise DelegateError(
495
+ "invalid_option_combination",
496
+ "--isolation is not supported with delegate worktree commands.",
497
+ )
498
+ return parse_worktree(rest, json_mode, cwd)
499
+ if subcommand == "profiles":
500
+ if isolation is not None:
501
+ raise DelegateError(
502
+ "invalid_option_combination",
503
+ "--isolation is not supported with delegate profiles.",
504
+ )
505
+ return parse_profiles(rest, json_mode, cwd, auth_profile)
506
+
507
+ raise DelegateError("unknown_subcommand", unknown_subcommand_message(subcommand))
508
+
509
+
510
+ def unknown_subcommand_message(subcommand: str) -> str:
511
+ suggestion_only = {
512
+ "codex-auth": "use: delegate profiles",
513
+ "kill": "use: delegate cancel ...",
514
+ }
515
+ if subcommand.startswith("droid-"):
516
+ model = subcommand.removeprefix("droid-")
517
+ suggestion_only[subcommand] = f"use: delegate droid {model} ..."
518
+ candidates = sorted(set(command_help.COMMAND_SPECS) | set(KNOWN_ENGINES))
519
+ matches = difflib.get_close_matches(subcommand, candidates, n=3)
520
+ parts = [f"Unknown subcommand: {subcommand}"]
521
+ if subcommand in suggestion_only:
522
+ parts.append(suggestion_only[subcommand])
523
+ elif matches:
524
+ parts.append(f"Did you mean: {', '.join(matches)}?")
525
+ top_level = sorted({name.split()[0] for name in command_help.COMMAND_SPECS})
526
+ if len(top_level) <= 20:
527
+ parts.append(f"Valid subcommands: {', '.join(top_level)}.")
528
+ return " ".join(parts)
529
+
530
+
531
+ def has_misplaced_global_option(tokens: list[str]) -> bool:
532
+ return any(token in MISPLACED_GLOBAL_OPTIONS for token in tokens)
533
+
534
+
535
+ def _shell_command(argv: list[str]) -> str:
536
+ return shlex.join(["delegate", *argv])
537
+
538
+
539
+ def corrected_global_command(argv: list[str]) -> str:
540
+ globals_out: list[str] = []
541
+ rest: list[str] = []
542
+ i = 0
543
+ while i < len(argv):
544
+ token = argv[i]
545
+ if token in VALUE_GLOBAL_OPTIONS and i + 1 < len(argv):
546
+ globals_out.extend([token, argv[i + 1]])
547
+ i += 2
548
+ continue
549
+ if token in MISPLACED_GLOBAL_OPTIONS:
550
+ globals_out.append(token)
551
+ i += 1
552
+ continue
553
+ rest.append(token)
554
+ i += 1
555
+ return _shell_command([*globals_out, *rest])
556
+
557
+
558
+ def raise_misplaced_global_option(message: str, argv: list[str] | None = None) -> NoReturn:
559
+ guidance = (
560
+ "Move global options before the subcommand "
561
+ "(for example: delegate --json --cwd PATH <subcommand> ...)."
562
+ )
563
+ corrected = f" Corrected command: {corrected_global_command(argv)}." if argv else ""
564
+ raise DelegateError("misplaced_global_option", f"{message} {guidance}{corrected}")
565
+
566
+
567
+ def unknown_option_message(command: str, option: str) -> str:
568
+ spec = command_help.COMMAND_SPECS.get(command)
569
+ candidates = [opt.flag for opt in spec.options] if spec is not None else []
570
+ matches = difflib.get_close_matches(option, candidates, n=3)
571
+ if command == "run-output" and option == "--full":
572
+ matches = ["--raw", *[match for match in matches if match != "--raw"]]
573
+ suffix = f" Did you mean: {', '.join(matches)}?" if matches else ""
574
+ return f"{command} does not support option: {option}.{suffix}"
575
+
576
+
577
+ def consume_json_option(rest: list[str], json_mode: bool) -> tuple[list[str], bool]:
578
+ """Accept `--json` after contained inspection commands.
579
+
580
+ Launching commands still require global options before the subcommand so
581
+ prompt boundaries stay unambiguous. For no-launch inspection commands,
582
+ accepting `delegate describe --json` and friends removes a common operator
583
+ foot-gun without changing child-runtime invocation semantics.
584
+ """
585
+
586
+ normalized: list[str] = []
587
+ for token in rest:
588
+ if token == "--json":
589
+ json_mode = True
590
+ continue
591
+ normalized.append(token)
592
+ return normalized, json_mode
593
+
594
+
595
+ def require_no_extra(rest: list[str], name: str) -> None:
596
+ if rest:
597
+ if has_misplaced_global_option(rest):
598
+ raise_misplaced_global_option("Global options must appear before the subcommand.")
599
+ raise DelegateError(
600
+ "unexpected_argument", f"{name} does not accept arguments: {' '.join(rest)}"
601
+ )
602
+
603
+
604
+ def parse_run(
605
+ rest: list[str],
606
+ json_mode: bool,
607
+ cwd: str | None,
608
+ pass_through: bool,
609
+ completion_report: str | None,
610
+ isolation: str | None,
611
+ auth_profile: str | None,
612
+ group: str | None = None,
613
+ ) -> ParsedCommand:
614
+ # Help wins before required-arg validation: `run --help` needs no --input-json.
615
+ if any(command_help.is_help_token(token) for token in rest):
616
+ return help_command(json_mode, "run")
617
+ if len(rest) != 2 or rest[0] != "--input-json":
618
+ if has_misplaced_global_option(rest):
619
+ raise_misplaced_global_option("Global options must appear before the subcommand.")
620
+ raise DelegateError("invalid_run_args", "run requires: --input-json FILE")
621
+ return ParsedCommand(
622
+ "run",
623
+ global_options=GlobalOptions(
624
+ json_mode=json_mode,
625
+ cwd=cwd,
626
+ pass_through=pass_through,
627
+ completion_report=completion_report,
628
+ isolation=isolation,
629
+ auth_profile=auth_profile,
630
+ group=group,
631
+ ),
632
+ run_json=RunJsonOptions(rest[1]),
633
+ )
634
+
635
+
636
+ def parse_modeless_engine(
637
+ engine: str,
638
+ rest: list[str],
639
+ json_mode: bool,
640
+ cwd: str | None,
641
+ dry_run: bool,
642
+ pass_through: bool,
643
+ completion_report: str | None,
644
+ isolation: str | None,
645
+ auth_profile: str | None,
646
+ group: str | None = None,
647
+ *,
648
+ help_topic: str | None = None,
649
+ ) -> ParsedCommand:
650
+ """Parse the shared cursor/codex grammar: <mode> [--prompt-file PATH] [prompt...]."""
651
+ topic = help_topic if help_topic is not None else engine
652
+ # Help wins before a mode is consumed: `cursor --help` needs no mode.
653
+ if rest and command_help.is_help_token(rest[0]):
654
+ return help_command(json_mode, topic)
655
+ if not rest:
656
+ raise DelegateError("missing_mode", f"{engine} requires mode: safe, work, or call.")
657
+ mode = rest[0]
658
+ if mode.startswith("-"):
659
+ raise DelegateError(
660
+ "misplaced_global_option", "Global options must appear before the subcommand."
661
+ )
662
+ validate_mode(mode)
663
+ # Help wins immediately after the mode, before prompt capture begins:
664
+ # `cursor safe --help`. Once a prompt positional begins, a later --help is
665
+ # prompt text (`cursor work explain --help`).
666
+ if len(rest) >= 2 and command_help.is_help_token(rest[1]):
667
+ return help_command(json_mode, topic)
668
+ (
669
+ prompt_file,
670
+ output_schema,
671
+ reasoning_effort,
672
+ progress_intent,
673
+ forbid_commit,
674
+ prompt_parts,
675
+ json_mode,
676
+ isolation,
677
+ read_only,
678
+ include_dirty,
679
+ ) = parse_prompt_tail(rest[1:], json_mode, isolation, command_prefix=[engine, mode])
680
+ forbid_commit_implied_isolation = False
681
+ if forbid_commit and mode == "work" and isolation is None:
682
+ isolation = "worktree"
683
+ forbid_commit_implied_isolation = True
684
+ if forbid_commit and mode == "work" and isolation == "none":
685
+ raise DelegateError(
686
+ "invalid_option_combination",
687
+ "--forbid-commit cannot be combined with --isolation none. "
688
+ f"Corrected command: {_shell_command(['--isolation', 'worktree', engine, mode, '--forbid-commit', *(prompt_parts or [])])}.",
689
+ )
690
+ return ParsedCommand(
691
+ engine,
692
+ global_options=GlobalOptions(
693
+ json_mode=json_mode,
694
+ cwd=cwd,
695
+ pass_through=pass_through,
696
+ completion_report=completion_report,
697
+ isolation=isolation,
698
+ auth_profile=auth_profile,
699
+ group=group,
700
+ ),
701
+ launch=LaunchOptions(
702
+ engine=engine,
703
+ mode=mode,
704
+ prompt_parts=prompt_parts,
705
+ prompt_file=prompt_file,
706
+ output_schema=output_schema,
707
+ reasoning_effort=reasoning_effort,
708
+ progress_intent=progress_intent,
709
+ forbid_commit=forbid_commit,
710
+ forbid_commit_implied_isolation=forbid_commit_implied_isolation,
711
+ include_dirty=include_dirty,
712
+ read_only=read_only,
713
+ dry_run=dry_run,
714
+ ),
715
+ )
716
+
717
+
718
+ def parse_droid(
719
+ rest: list[str],
720
+ json_mode: bool,
721
+ cwd: str | None,
722
+ dry_run: bool,
723
+ pass_through: bool,
724
+ completion_report: str | None,
725
+ isolation: str | None,
726
+ auth_profile: str | None,
727
+ group: str | None = None,
728
+ *,
729
+ help_topic: str | None = None,
730
+ ) -> ParsedCommand:
731
+ topic = help_topic if help_topic is not None else "droid"
732
+ # Help wins before the alias is consumed: `droid --help` needs no alias.
733
+ if rest and command_help.is_help_token(rest[0]):
734
+ return help_command(json_mode, topic)
735
+ if len(rest) < 2:
736
+ raise DelegateError("missing_droid_args", "droid requires MODEL_ALIAS and mode.")
737
+ model_alias = rest[0]
738
+ if model_alias.startswith("-"):
739
+ raise DelegateError(
740
+ "misplaced_global_option", "Global options must appear before the subcommand."
741
+ )
742
+ # Help wins after the alias, before the mode: `droid x --help`.
743
+ if command_help.is_help_token(rest[1]):
744
+ return help_command(json_mode, topic)
745
+ mode = rest[1]
746
+ if mode.startswith("-"):
747
+ raise DelegateError(
748
+ "misplaced_global_option", "Global options must appear before the subcommand."
749
+ )
750
+ validate_mode(mode)
751
+ # Help wins after the mode, before prompt capture: `droid x safe --help`.
752
+ if len(rest) >= 3 and command_help.is_help_token(rest[2]):
753
+ return help_command(json_mode, topic)
754
+ (
755
+ prompt_file,
756
+ output_schema,
757
+ reasoning_effort,
758
+ progress_intent,
759
+ forbid_commit,
760
+ prompt_parts,
761
+ json_mode,
762
+ isolation,
763
+ read_only,
764
+ include_dirty,
765
+ ) = parse_prompt_tail(
766
+ rest[2:], json_mode, isolation, command_prefix=["droid", model_alias, mode]
767
+ )
768
+ forbid_commit_implied_isolation = False
769
+ if forbid_commit and mode == "work" and isolation is None:
770
+ isolation = "worktree"
771
+ forbid_commit_implied_isolation = True
772
+ if forbid_commit and mode == "work" and isolation == "none":
773
+ raise DelegateError(
774
+ "invalid_option_combination",
775
+ "--forbid-commit cannot be combined with --isolation none. "
776
+ f"Corrected command: {_shell_command(['--isolation', 'worktree', 'droid', model_alias, mode, '--forbid-commit', *(prompt_parts or [])])}.",
777
+ )
778
+ return ParsedCommand(
779
+ "droid",
780
+ global_options=GlobalOptions(
781
+ json_mode=json_mode,
782
+ cwd=cwd,
783
+ pass_through=pass_through,
784
+ completion_report=completion_report,
785
+ isolation=isolation,
786
+ auth_profile=auth_profile,
787
+ group=group,
788
+ ),
789
+ launch=LaunchOptions(
790
+ engine="droid",
791
+ mode=mode,
792
+ model_alias=model_alias,
793
+ prompt_parts=prompt_parts,
794
+ prompt_file=prompt_file,
795
+ output_schema=output_schema,
796
+ reasoning_effort=reasoning_effort,
797
+ progress_intent=progress_intent,
798
+ forbid_commit=forbid_commit,
799
+ forbid_commit_implied_isolation=forbid_commit_implied_isolation,
800
+ include_dirty=include_dirty,
801
+ read_only=read_only,
802
+ dry_run=dry_run,
803
+ ),
804
+ )
805
+
806
+
807
+ def parse_dry_run(
808
+ rest: list[str],
809
+ json_mode: bool,
810
+ cwd: str | None,
811
+ pass_through: bool,
812
+ completion_report: str | None,
813
+ isolation: str | None,
814
+ auth_profile: str | None,
815
+ group: str | None = None,
816
+ ) -> ParsedCommand:
817
+ # Help wins before the engine is consumed: `dry-run --help`.
818
+ if rest and command_help.is_help_token(rest[0]):
819
+ return help_command(json_mode, "dry-run")
820
+ if not rest:
821
+ raise DelegateError(
822
+ "missing_engine",
823
+ "dry-run requires cursor, droid, codex, kimi, claude, or grok.",
824
+ )
825
+ engine = rest[0]
826
+ if engine.startswith("-"):
827
+ raise DelegateError(
828
+ "misplaced_global_option", "Global options must appear before the subcommand."
829
+ )
830
+ if engine in MODELESS_ENGINES:
831
+ return parse_modeless_engine(
832
+ engine,
833
+ rest[1:],
834
+ json_mode,
835
+ cwd,
836
+ dry_run=True,
837
+ pass_through=pass_through,
838
+ completion_report=completion_report,
839
+ isolation=isolation,
840
+ auth_profile=auth_profile,
841
+ group=group,
842
+ help_topic="dry-run",
843
+ )
844
+ if engine == "droid":
845
+ return parse_droid(
846
+ rest[1:],
847
+ json_mode,
848
+ cwd,
849
+ dry_run=True,
850
+ pass_through=pass_through,
851
+ completion_report=completion_report,
852
+ isolation=isolation,
853
+ auth_profile=auth_profile,
854
+ group=group,
855
+ help_topic="dry-run",
856
+ )
857
+ raise DelegateError(
858
+ "invalid_engine",
859
+ f"dry-run engine must be {ENGINES_PROSE}.",
860
+ )
861
+
862
+
863
+ def parse_prompt_tail(
864
+ rest: list[str],
865
+ json_mode: bool,
866
+ isolation: str | None,
867
+ *,
868
+ command_prefix: list[str] | None = None,
869
+ ) -> tuple[
870
+ str | None,
871
+ str | None,
872
+ str | None,
873
+ str | None,
874
+ bool,
875
+ list[str],
876
+ bool,
877
+ str | None,
878
+ bool,
879
+ bool,
880
+ ]:
881
+ prompt_file: str | None = None
882
+ output_schema: str | None = None
883
+ reasoning_effort: str | None = None
884
+ progress_intent: str | None = None
885
+ forbid_commit = False
886
+ include_dirty = False
887
+ read_only = False
888
+ prompt_parts: list[str] = []
889
+ i = 0
890
+ while i < len(rest):
891
+ token = rest[i]
892
+ # `--json` is unambiguous anywhere before inline prompt text starts (e.g.
893
+ # after --prompt-file), so accept it here instead of forcing it ahead of the
894
+ # subcommand. Once prompt text begins it lands in prompt_parts and the
895
+ # post-loop misplaced-global guard rejects it, since then it could be prompt
896
+ # text. Mirrors consume_json_option for inspection commands.
897
+ if token == "--json":
898
+ json_mode = True
899
+ i += 1
900
+ continue
901
+ if token == "--prompt-file":
902
+ if prompt_parts:
903
+ raise DelegateError(
904
+ "ambiguous_prompt_source",
905
+ "--prompt-file must appear before direct prompt text."
906
+ + corrected_prompt_file_suffix(command_prefix, rest),
907
+ )
908
+ if prompt_file is not None:
909
+ raise DelegateError("ambiguous_prompt_source", "Only one --prompt-file is allowed.")
910
+ if i + 1 >= len(rest):
911
+ raise DelegateError("missing_prompt_file", "--prompt-file requires a path.")
912
+ prompt_file = rest[i + 1]
913
+ i += 2
914
+ continue
915
+ if token == "--isolation":
916
+ if i + 1 >= len(rest):
917
+ raise DelegateError("missing_isolation_value", "--isolation requires a value.")
918
+ isolation = rest[i + 1]
919
+ if isolation not in delegate_config.VALID_ISOLATION_VALUES:
920
+ raise DelegateError(
921
+ "invalid_isolation",
922
+ "--isolation must be auto, none, or worktree.",
923
+ )
924
+ i += 2
925
+ continue
926
+ if token == "--output-schema":
927
+ if output_schema is not None:
928
+ raise DelegateError("invalid_output_schema", "Only one --output-schema is allowed.")
929
+ if i + 1 >= len(rest):
930
+ raise DelegateError("missing_output_schema", "--output-schema requires a path.")
931
+ output_schema = rest[i + 1]
932
+ i += 2
933
+ continue
934
+ if token == "--reasoning-effort":
935
+ if reasoning_effort is not None:
936
+ raise DelegateError(
937
+ "invalid_reasoning_effort",
938
+ "Only one --reasoning-effort is allowed.",
939
+ )
940
+ if i + 1 >= len(rest):
941
+ raise DelegateError(
942
+ "missing_reasoning_effort",
943
+ "--reasoning-effort requires a value.",
944
+ )
945
+ value = rest[i + 1]
946
+ if value.startswith("-") or command_help.is_help_token(value):
947
+ raise DelegateError(
948
+ "missing_reasoning_effort",
949
+ "--reasoning-effort requires a value.",
950
+ )
951
+ try:
952
+ reasoning_effort = reasoning.normalize_effort(value)
953
+ except reasoning.ReasoningCapabilityError as exc:
954
+ corrected = corrected_drop_option_suffix(command_prefix, rest, i, takes_value=True)
955
+ raise DelegateError(exc.error, f"{exc.message}{corrected}") from exc
956
+ i += 2
957
+ continue
958
+ if token == "--progress":
959
+ if progress_intent == "on":
960
+ raise DelegateError(
961
+ "invalid_option_combination",
962
+ "Only one --progress flag is allowed.",
963
+ )
964
+ if progress_intent == "off":
965
+ raise DelegateError(
966
+ "invalid_option_combination",
967
+ "--progress and --no-progress cannot be combined.",
968
+ )
969
+ progress_intent = "on"
970
+ i += 1
971
+ continue
972
+ if token == "--no-progress":
973
+ if progress_intent == "off":
974
+ raise DelegateError(
975
+ "invalid_option_combination",
976
+ "Only one --no-progress flag is allowed.",
977
+ )
978
+ if progress_intent == "on":
979
+ raise DelegateError(
980
+ "invalid_option_combination",
981
+ "--progress and --no-progress cannot be combined.",
982
+ )
983
+ progress_intent = "off"
984
+ i += 1
985
+ continue
986
+ if token == "--forbid-commit":
987
+ if forbid_commit:
988
+ raise DelegateError(
989
+ "invalid_option_combination",
990
+ "Only one --forbid-commit flag is allowed.",
991
+ )
992
+ forbid_commit = True
993
+ i += 1
994
+ continue
995
+ if token == "--include-dirty":
996
+ if include_dirty:
997
+ raise DelegateError(
998
+ "invalid_option_combination",
999
+ "Only one --include-dirty flag is allowed.",
1000
+ )
1001
+ include_dirty = True
1002
+ i += 1
1003
+ continue
1004
+ if token == "--read-only":
1005
+ read_only = True
1006
+ i += 1
1007
+ continue
1008
+ prompt_parts = rest[i:]
1009
+ break
1010
+ if "--prompt-file" in prompt_parts:
1011
+ raise DelegateError(
1012
+ "ambiguous_prompt_source",
1013
+ "--prompt-file must appear before direct prompt text."
1014
+ + corrected_prompt_file_suffix(command_prefix, rest),
1015
+ )
1016
+ if "--output-schema" in prompt_parts:
1017
+ raise DelegateError(
1018
+ "invalid_output_schema", "--output-schema must appear before direct prompt text."
1019
+ )
1020
+ if has_misplaced_global_option(prompt_parts):
1021
+ raise_misplaced_global_option(
1022
+ "Global options must appear before the subcommand; use --prompt-file for literal flag text.",
1023
+ )
1024
+ return (
1025
+ prompt_file,
1026
+ output_schema,
1027
+ reasoning_effort,
1028
+ progress_intent,
1029
+ forbid_commit,
1030
+ prompt_parts,
1031
+ json_mode,
1032
+ isolation,
1033
+ read_only,
1034
+ include_dirty,
1035
+ )
1036
+
1037
+
1038
+ def parse_snapshot(rest: list[str], json_mode: bool, cwd: str | None) -> ParsedCommand:
1039
+ # Help wins over the optional handle/flags: a help token anywhere is help.
1040
+ rest, json_mode = consume_json_option(rest, json_mode)
1041
+ if any(command_help.is_help_token(token) for token in rest):
1042
+ return help_command(json_mode, "snapshot")
1043
+ latest_harness: str | None = None
1044
+ no_redact = False
1045
+ handle: str | None = None
1046
+ i = 0
1047
+ while i < len(rest):
1048
+ token = rest[i]
1049
+ if token == "--latest":
1050
+ if i + 1 >= len(rest):
1051
+ raise DelegateError("missing_harness", "snapshot --latest requires a harness name.")
1052
+ latest_harness = rest[i + 1]
1053
+ i += 2
1054
+ continue
1055
+ if token == "--no-redact":
1056
+ no_redact = True
1057
+ i += 1
1058
+ continue
1059
+ if token.startswith("-"):
1060
+ raise DelegateError("unknown_option", unknown_option_message("snapshot", token))
1061
+ if handle is not None:
1062
+ raise DelegateError(
1063
+ "unexpected_argument", f"snapshot accepts one handle: {' '.join(rest)}"
1064
+ )
1065
+ handle = token
1066
+ i += 1
1067
+ if latest_harness is None and handle is None:
1068
+ raise DelegateError(
1069
+ "missing_handle", "snapshot requires <alias-or-runId> or --latest <harness>."
1070
+ )
1071
+ if latest_harness is not None and handle is not None:
1072
+ raise DelegateError(
1073
+ "ambiguous_snapshot_target",
1074
+ "Use either --latest <harness> or an exact handle, not both.",
1075
+ )
1076
+ return ParsedCommand(
1077
+ "snapshot",
1078
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd),
1079
+ snapshot=inspection_commands.SnapshotCommand(
1080
+ handle=handle,
1081
+ latest_harness=latest_harness,
1082
+ no_redact=no_redact,
1083
+ json_mode=json_mode,
1084
+ ),
1085
+ )
1086
+
1087
+
1088
+ def parse_runs(rest: list[str], json_mode: bool, cwd: str | None) -> ParsedCommand:
1089
+ # Help wins over flags: a help token anywhere is help.
1090
+ rest, json_mode = consume_json_option(rest, json_mode)
1091
+ if any(command_help.is_help_token(token) for token in rest):
1092
+ return help_command(json_mode, "runs")
1093
+ active = False
1094
+ recent = False
1095
+ running = False
1096
+ stale = False
1097
+ harness: str | None = None
1098
+ group: str | None = None
1099
+ limit: int | None = None
1100
+ i = 0
1101
+ while i < len(rest):
1102
+ token = rest[i]
1103
+ if token == "--active":
1104
+ active = True
1105
+ i += 1
1106
+ continue
1107
+ if token == "--running":
1108
+ running = True
1109
+ i += 1
1110
+ continue
1111
+ if token == "--stale":
1112
+ stale = True
1113
+ i += 1
1114
+ continue
1115
+ if token == "--recent":
1116
+ recent = True
1117
+ i += 1
1118
+ continue
1119
+ if token == "--harness":
1120
+ if i + 1 >= len(rest):
1121
+ raise DelegateError("missing_harness", "runs --harness requires a harness name.")
1122
+ harness = rest[i + 1]
1123
+ if harness not in KNOWN_ENGINES:
1124
+ raise DelegateError(
1125
+ "invalid_harness",
1126
+ f"runs --harness must be one of {', '.join(KNOWN_ENGINES)}.",
1127
+ )
1128
+ i += 2
1129
+ continue
1130
+ if token == "--group":
1131
+ if i + 1 >= len(rest):
1132
+ raise DelegateError("missing_group", "runs --group requires a group name.")
1133
+ group = validate_group(rest[i + 1])
1134
+ i += 2
1135
+ continue
1136
+ if token == "--limit":
1137
+ limit, i = parse_required_positive_int_option(
1138
+ rest,
1139
+ i,
1140
+ option_label="runs --limit",
1141
+ missing_error="missing_limit",
1142
+ invalid_error="invalid_limit",
1143
+ )
1144
+ continue
1145
+ raise DelegateError("unknown_option", f"runs does not support option: {token}")
1146
+ selected_modes = [
1147
+ label
1148
+ for label, selected in (
1149
+ ("--active", active),
1150
+ ("--running", running),
1151
+ ("--stale", stale),
1152
+ ("--recent", recent),
1153
+ )
1154
+ if selected
1155
+ ]
1156
+ if len(selected_modes) > 1:
1157
+ raise DelegateError(
1158
+ "invalid_option_combination",
1159
+ f"runs filters are mutually exclusive: {', '.join(selected_modes)}.",
1160
+ )
1161
+ return ParsedCommand(
1162
+ "runs",
1163
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd),
1164
+ runs=inspection_commands.RunsCommand(
1165
+ active=active,
1166
+ running=running,
1167
+ stale=stale,
1168
+ harness=harness,
1169
+ group=group,
1170
+ limit=limit,
1171
+ json_mode=json_mode,
1172
+ ),
1173
+ )
1174
+
1175
+
1176
+ def parse_run_output(rest: list[str], json_mode: bool, cwd: str | None) -> ParsedCommand:
1177
+ # Help wins over the required handle/selectors: a help token anywhere is help.
1178
+ rest, json_mode = consume_json_option(rest, json_mode)
1179
+ if any(command_help.is_help_token(token) for token in rest):
1180
+ return help_command(json_mode, "run-output")
1181
+ if not rest:
1182
+ raise DelegateError("missing_handle", "run-output requires <alias-or-runId> or --latest.")
1183
+ handle: str | None = None
1184
+ latest_harness: str | None = None
1185
+ completion_report = False
1186
+ stdout_flag = False
1187
+ stderr_flag = False
1188
+ tail: int | None = None
1189
+ max_chars: int | None = None
1190
+ raw = False
1191
+ no_redact = False
1192
+ i = 0
1193
+ while i < len(rest):
1194
+ token = rest[i]
1195
+ if token == "--latest":
1196
+ if i + 1 >= len(rest):
1197
+ raise DelegateError(
1198
+ "missing_harness", "run-output --latest requires a harness name."
1199
+ )
1200
+ latest_harness = rest[i + 1]
1201
+ i += 2
1202
+ continue
1203
+ if token == "--completion-report":
1204
+ completion_report = True
1205
+ i += 1
1206
+ continue
1207
+ if token == "--stdout":
1208
+ stdout_flag = True
1209
+ i += 1
1210
+ continue
1211
+ if token == "--stderr":
1212
+ stderr_flag = True
1213
+ i += 1
1214
+ continue
1215
+ if token == "--raw":
1216
+ raw = True
1217
+ i += 1
1218
+ continue
1219
+ if token == "--no-redact":
1220
+ no_redact = True
1221
+ i += 1
1222
+ continue
1223
+ if token == "--tail":
1224
+ tail, i = parse_required_positive_int_option(
1225
+ rest,
1226
+ i,
1227
+ option_label="run-output --tail",
1228
+ missing_error="missing_tail",
1229
+ invalid_error="invalid_tail",
1230
+ missing_value_description="a line count",
1231
+ )
1232
+ continue
1233
+ if token == "--max-chars":
1234
+ max_chars, i = parse_required_positive_int_option(
1235
+ rest,
1236
+ i,
1237
+ option_label="run-output --max-chars",
1238
+ missing_error="missing_max_chars",
1239
+ invalid_error="invalid_max_chars",
1240
+ missing_value_description="a positive integer",
1241
+ )
1242
+ continue
1243
+ if token.startswith("-"):
1244
+ raise DelegateError("unknown_option", unknown_option_message("run-output", token))
1245
+ if handle is not None:
1246
+ raise DelegateError(
1247
+ "unexpected_argument", f"run-output accepts one handle: {' '.join(rest)}"
1248
+ )
1249
+ handle = token
1250
+ i += 1
1251
+ if latest_harness is None and handle is None:
1252
+ raise DelegateError("missing_handle", "run-output requires <alias-or-runId> or --latest.")
1253
+ if latest_harness is not None and handle is not None:
1254
+ raise DelegateError(
1255
+ "ambiguous_run_output_target",
1256
+ "Use either --latest <harness> or an exact handle, not both.",
1257
+ )
1258
+ default_output = not (completion_report or stdout_flag or stderr_flag or raw)
1259
+ if default_output:
1260
+ completion_report = True
1261
+ if raw and tail is not None:
1262
+ raise DelegateError(
1263
+ "invalid_option_combination",
1264
+ "run-output --raw cannot be combined with --tail.",
1265
+ )
1266
+ if raw and max_chars is not None:
1267
+ raise DelegateError(
1268
+ "invalid_option_combination",
1269
+ "run-output --raw cannot be combined with --max-chars.",
1270
+ )
1271
+ if not (stdout_flag or stderr_flag or raw) and (tail is not None or max_chars is not None):
1272
+ raise DelegateError(
1273
+ "invalid_option_combination",
1274
+ "run-output --tail/--max-chars only apply to --stdout or --stderr; "
1275
+ "add --stdout/--stderr or remove the ignored flag.",
1276
+ )
1277
+ if (stdout_flag or stderr_flag) and not raw and tail is None:
1278
+ tail = RUN_OUTPUT_DEFAULT_TAIL_LINES
1279
+ return ParsedCommand(
1280
+ "run-output",
1281
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd),
1282
+ run_output=run_output_commands.RunOutputCommand(
1283
+ handle=handle,
1284
+ latest_harness=latest_harness,
1285
+ json_mode=json_mode,
1286
+ completion_report=completion_report,
1287
+ stdout=stdout_flag,
1288
+ stderr=stderr_flag,
1289
+ tail=tail,
1290
+ max_chars=max_chars,
1291
+ raw=raw,
1292
+ no_redact=no_redact,
1293
+ default=default_output,
1294
+ ),
1295
+ )
1296
+
1297
+
1298
+ def parse_wait(rest: list[str], json_mode: bool, cwd: str | None) -> ParsedCommand:
1299
+ rest, json_mode = consume_json_option(rest, json_mode)
1300
+ if any(command_help.is_help_token(token) for token in rest):
1301
+ return help_command(json_mode, "wait")
1302
+ handles: list[str] = []
1303
+ latest_harness: str | None = None
1304
+ group: str | None = None
1305
+ timeout = wait_cancel_commands.WAIT_DEFAULT_TIMEOUT_SECONDS
1306
+ interval = wait_cancel_commands.WAIT_DEFAULT_INTERVAL_SECONDS
1307
+ completion_report = False
1308
+ i = 0
1309
+ while i < len(rest):
1310
+ token = rest[i]
1311
+ if token == "--latest":
1312
+ if i + 1 >= len(rest):
1313
+ raise DelegateError("missing_harness", "wait --latest requires a harness name.")
1314
+ latest_harness = rest[i + 1]
1315
+ i += 2
1316
+ continue
1317
+ if token == "--timeout":
1318
+ timeout, i = parse_required_positive_int_option(
1319
+ rest,
1320
+ i,
1321
+ option_label="wait --timeout",
1322
+ missing_error="missing_timeout",
1323
+ invalid_error="invalid_timeout",
1324
+ missing_value_description="seconds",
1325
+ )
1326
+ continue
1327
+ if token == "--group":
1328
+ if i + 1 >= len(rest):
1329
+ raise DelegateError("missing_group", "wait --group requires a group name.")
1330
+ group = validate_group(rest[i + 1])
1331
+ i += 2
1332
+ continue
1333
+ if token == "--interval":
1334
+ interval, i = parse_required_positive_int_option(
1335
+ rest,
1336
+ i,
1337
+ option_label="wait --interval",
1338
+ missing_error="missing_interval",
1339
+ invalid_error="invalid_interval",
1340
+ missing_value_description="seconds",
1341
+ )
1342
+ interval = max(interval, wait_cancel_commands.WAIT_MIN_INTERVAL_SECONDS)
1343
+ continue
1344
+ if token == "--completion-report":
1345
+ completion_report = True
1346
+ i += 1
1347
+ continue
1348
+ if token.startswith("-"):
1349
+ raise DelegateError("unknown_option", unknown_option_message("wait", token))
1350
+ handles.append(token)
1351
+ i += 1
1352
+ if not handles and latest_harness is None and group is None:
1353
+ raise DelegateError(
1354
+ "missing_handle", "wait requires a handle, --latest <harness>, or --group <name>."
1355
+ )
1356
+ return ParsedCommand(
1357
+ "wait",
1358
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd),
1359
+ wait_command=wait_cancel_commands.WaitCommand(
1360
+ handles=tuple(handles),
1361
+ latest_harness=latest_harness,
1362
+ group=group,
1363
+ timeout_seconds=timeout,
1364
+ interval_seconds=interval,
1365
+ completion_report=completion_report,
1366
+ json_mode=json_mode,
1367
+ ),
1368
+ )
1369
+
1370
+
1371
+ def parse_cancel(rest: list[str], json_mode: bool, cwd: str | None) -> ParsedCommand:
1372
+ rest, json_mode = consume_json_option(rest, json_mode)
1373
+ if any(command_help.is_help_token(token) for token in rest):
1374
+ return help_command(json_mode, "cancel")
1375
+ handles: list[str] = []
1376
+ for token in rest:
1377
+ if token.startswith("-"):
1378
+ raise DelegateError("unknown_option", unknown_option_message("cancel", token))
1379
+ handles.append(token)
1380
+ if not handles:
1381
+ raise DelegateError("missing_handle", "cancel requires at least one run handle.")
1382
+ return ParsedCommand(
1383
+ "cancel",
1384
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd),
1385
+ cancel_command=wait_cancel_commands.CancelCommand(tuple(handles), json_mode=json_mode),
1386
+ )
1387
+
1388
+
1389
+ def corrected_prompt_file_suffix(command_prefix: list[str] | None, rest: list[str]) -> str:
1390
+ if not command_prefix or "--prompt-file" not in rest:
1391
+ return ""
1392
+ prompt_index = rest.index("--prompt-file")
1393
+ if prompt_index + 1 >= len(rest):
1394
+ return ""
1395
+ prompt_file = rest[prompt_index + 1]
1396
+ before = [token for token in rest[:prompt_index] if token != "--json"]
1397
+ after = rest[prompt_index + 2 :]
1398
+ corrected = [*command_prefix, "--prompt-file", prompt_file, *before, *after]
1399
+ return f" Corrected command: {_shell_command(corrected)}."
1400
+
1401
+
1402
+ def corrected_drop_option_suffix(
1403
+ command_prefix: list[str] | None,
1404
+ rest: list[str],
1405
+ index: int,
1406
+ *,
1407
+ takes_value: bool,
1408
+ ) -> str:
1409
+ if not command_prefix:
1410
+ return ""
1411
+ drop = 2 if takes_value else 1
1412
+ corrected = [*command_prefix, *rest[:index], *rest[index + drop :]]
1413
+ return f" Corrected command: {_shell_command(corrected)}."
1414
+
1415
+
1416
+ def parse_non_negative_int(value: str, *, option: str) -> int:
1417
+ try:
1418
+ parsed = int(value)
1419
+ except ValueError:
1420
+ raise DelegateError("invalid_option_value", f"{option} must be an integer.") from None
1421
+ if parsed < 0:
1422
+ raise DelegateError("invalid_option_value", f"{option} must be non-negative.")
1423
+ return parsed
1424
+
1425
+
1426
+ def parse_positive_int(value: str, *, option: str) -> int:
1427
+ parsed = parse_non_negative_int(value, option=option)
1428
+ if parsed < 1:
1429
+ raise DelegateError("invalid_option_value", f"{option} must be at least 1.")
1430
+ return parsed
1431
+
1432
+
1433
+ def parse_required_positive_int_option(
1434
+ rest: list[str],
1435
+ index: int,
1436
+ *,
1437
+ option_label: str,
1438
+ missing_error: str,
1439
+ invalid_error: str,
1440
+ missing_value_description: str = "a positive integer",
1441
+ ) -> tuple[int, int]:
1442
+ if index + 1 >= len(rest):
1443
+ raise DelegateError(
1444
+ missing_error,
1445
+ f"{option_label} requires {missing_value_description}.",
1446
+ )
1447
+ try:
1448
+ parsed = int(rest[index + 1])
1449
+ except ValueError as exc:
1450
+ raise DelegateError(invalid_error, f"{option_label} must be a positive integer.") from exc
1451
+ if parsed < 1:
1452
+ raise DelegateError(invalid_error, f"{option_label} must be at least 1.")
1453
+ return parsed, index + 2
1454
+
1455
+
1456
+ def _require_option_value(rest: list[str], index: int, option: str) -> str:
1457
+ if index + 1 >= len(rest):
1458
+ raise DelegateError("missing_option_value", f"{option} requires a value.")
1459
+ value = rest[index + 1]
1460
+ if value.startswith("-"):
1461
+ raise DelegateError("missing_option_value", f"{option} requires a value.")
1462
+ return value
1463
+
1464
+
1465
+ WorktreeOptionSpec = tuple[str, str]
1466
+
1467
+ WORKTREE_OPTION_SPECS: dict[str, dict[str, WorktreeOptionSpec]] = {
1468
+ "list": {
1469
+ "--harness": ("str", "harness"),
1470
+ "--group": ("group", "group"),
1471
+ "--status": ("status", "status"),
1472
+ "--limit": ("positive_int", "limit"),
1473
+ "--no-auto-prune": ("flag", "no_auto_prune"),
1474
+ },
1475
+ "show": {
1476
+ "--latest": ("str", "latest_harness"),
1477
+ },
1478
+ "remove": {
1479
+ "--group": ("group", "group"),
1480
+ "--discard-uncommitted": ("flag", "discard_uncommitted"),
1481
+ "--force-branch": ("flag", "force_branch"),
1482
+ "--force": ("flag", "force"),
1483
+ "--keep-branch": ("flag", "keep_branch"),
1484
+ },
1485
+ "prune": {
1486
+ "--merged": ("flag", "merged"),
1487
+ "--older-than": ("non_negative_int", "older_than_days"),
1488
+ "--harness": ("str", "harness"),
1489
+ "--group": ("group", "group"),
1490
+ "--include-detached": ("flag", "include_detached"),
1491
+ "--dry-run": ("flag", "dry_run"),
1492
+ "--discard-uncommitted": ("flag", "discard_uncommitted"),
1493
+ "--force-branch": ("flag", "force_branch"),
1494
+ "--force": ("flag", "force"),
1495
+ },
1496
+ "gc": {
1497
+ "--dry-run": ("flag", "dry_run"),
1498
+ },
1499
+ }
1500
+
1501
+
1502
+ def _apply_worktree_option(
1503
+ options: dict[str, object],
1504
+ args: list[str],
1505
+ index: int,
1506
+ option: str,
1507
+ spec: WorktreeOptionSpec,
1508
+ ) -> int:
1509
+ kind, attr = spec
1510
+ if kind == "flag":
1511
+ options[attr] = True
1512
+ return index + 1
1513
+ value = _require_option_value(args, index, option)
1514
+ if kind == "group":
1515
+ options[attr] = validate_group(value, option=option)
1516
+ elif kind == "str":
1517
+ options[attr] = value
1518
+ elif kind == "status":
1519
+ if value not in worktree_mgmt.VALID_STATUSES:
1520
+ raise DelegateError(
1521
+ "invalid_option_value",
1522
+ "--status must be present, removed, missing, or unknown.",
1523
+ )
1524
+ options[attr] = value
1525
+ elif kind == "positive_int":
1526
+ options[attr] = parse_positive_int(value, option=option)
1527
+ elif kind == "non_negative_int":
1528
+ options[attr] = parse_non_negative_int(value, option=option)
1529
+ else: # pragma: no cover - table construction bug
1530
+ raise AssertionError(f"unknown worktree option kind: {kind}")
1531
+ return index + 2
1532
+
1533
+
1534
+ def parse_worktree(rest: list[str], json_mode: bool, cwd: str | None) -> ParsedCommand:
1535
+ # Help wins before an action is consumed: `worktree --help`.
1536
+ if rest and command_help.is_help_token(rest[0]):
1537
+ return help_command(json_mode, "worktree")
1538
+ if not rest:
1539
+ raise DelegateError(
1540
+ "missing_worktree_action", "worktree requires list, show, remove, prune, or gc."
1541
+ )
1542
+ action = rest[0]
1543
+ args = rest[1:]
1544
+ # Destructive safety: a help token anywhere in an action's args makes help
1545
+ # win, so no removal/prune ever fires when the user asked for help. An
1546
+ # unknown action with a help token falls back to the worktree overview.
1547
+ if any(command_help.is_help_token(token) for token in args):
1548
+ topic = f"worktree {action}" if action in WORKTREE_OPTION_SPECS else "worktree"
1549
+ return help_command(json_mode, topic)
1550
+ if action not in WORKTREE_OPTION_SPECS:
1551
+ raise DelegateError("unknown_worktree_action", f"Unknown worktree action: {action}")
1552
+ options: dict[str, object] = {}
1553
+ positional: list[str] = []
1554
+ i = 0
1555
+ action_specs = WORKTREE_OPTION_SPECS[action]
1556
+ while i < len(args):
1557
+ token = args[i]
1558
+ spec = action_specs.get(token)
1559
+ if spec is not None:
1560
+ i = _apply_worktree_option(options, args, i, token, spec)
1561
+ continue
1562
+ if token in MISPLACED_GLOBAL_OPTIONS:
1563
+ raise_misplaced_global_option(f"{token} must appear before the subcommand.")
1564
+ if token.startswith("--"):
1565
+ raise DelegateError(
1566
+ "unknown_option", f"worktree {action} does not support option: {token}"
1567
+ )
1568
+ positional.append(token)
1569
+ i += 1
1570
+
1571
+ if action in {"list", "prune", "gc"} and positional:
1572
+ raise DelegateError(
1573
+ "unexpected_argument", f"worktree {action} does not accept positional arguments."
1574
+ )
1575
+ if action == "show":
1576
+ if options.get("latest_harness") is not None:
1577
+ if positional:
1578
+ raise DelegateError(
1579
+ "invalid_option_combination",
1580
+ "worktree show accepts either --latest HARNESS or a handle, not both.",
1581
+ )
1582
+ elif len(positional) != 1:
1583
+ raise DelegateError("missing_handle", "worktree show requires an alias or run id.")
1584
+ else:
1585
+ options["handle"] = positional[0]
1586
+ if action == "remove":
1587
+ if options.get("group") is not None:
1588
+ if positional:
1589
+ raise DelegateError(
1590
+ "invalid_option_combination",
1591
+ "worktree remove accepts either --group NAME or a handle, not both.",
1592
+ )
1593
+ elif len(positional) != 1:
1594
+ raise DelegateError("missing_handle", "worktree remove requires an alias or run id.")
1595
+ else:
1596
+ options["handle"] = positional[0]
1597
+ if options.get("keep_branch") and (options.get("force_branch") or options.get("force")):
1598
+ raise DelegateError(
1599
+ "invalid_option_combination",
1600
+ "worktree remove --keep-branch is mutually exclusive with --force-branch/--force.",
1601
+ )
1602
+ return ParsedCommand(
1603
+ "worktree",
1604
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd),
1605
+ worktree=worktree_commands.WorktreeCommand(
1606
+ action=action,
1607
+ json_mode=json_mode,
1608
+ **options,
1609
+ ),
1610
+ )
1611
+
1612
+
1613
+ def parse_profiles(
1614
+ rest: list[str],
1615
+ json_mode: bool,
1616
+ cwd: str | None,
1617
+ auth_profile: str | None,
1618
+ ) -> ParsedCommand:
1619
+ rest, json_mode = consume_json_option(rest, json_mode)
1620
+ if any(command_help.is_help_token(token) for token in rest):
1621
+ return help_command(json_mode, "profiles")
1622
+ require_no_extra(rest, "profiles")
1623
+ return ParsedCommand(
1624
+ "profiles",
1625
+ global_options=GlobalOptions(json_mode=json_mode, cwd=cwd, auth_profile=auth_profile),
1626
+ profiles_command=profile_commands.ProfilesCommand(json_mode=json_mode),
1627
+ )