-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.ts
More file actions
447 lines (409 loc) · 17.2 KB
/
cli.ts
File metadata and controls
447 lines (409 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
/**
* CLI entry point for `dispatch`.
*
* This module is a thin argument-parsing shell. It parses CLI arguments,
* boots the orchestrator, delegates all workflow logic (config loading,
* validation, pipeline selection) to the orchestrator's `runFromCli()`
* method, and exits based on the result.
*
* Process-level concerns (signal handlers, config subcommand) remain here.
*/
import { resolve, join } from "node:path";
import { Command, Option, CommanderError } from "commander";
import { boot as bootOrchestrator, type RawCliArgs } from "./orchestrator/runner.js";
import { log } from "./helpers/logger.js";
import { runCleanup } from "./helpers/cleanup.js";
import type { ProviderName } from "./providers/interface.js";
import type { DatasourceName } from "./datasources/interface.js";
import { PROVIDER_NAMES } from "./providers/index.js";
import { DATASOURCE_NAMES } from "./datasources/index.js";
import { handleConfigCommand, CONFIG_BOUNDS } from "./config.js";
export const MAX_CONCURRENCY = CONFIG_BOUNDS.concurrency.max;
export const HELP = `
dispatch — AI agent orchestration CLI
Usage:
dispatch [issue-id...] Dispatch specific issues (or all open issues if none given)
dispatch --spec <ids> Generate spec files from issues
dispatch --spec <glob> Generate specs from local markdown files in the configured datasource
dispatch --respec Regenerate all existing specs
dispatch --respec <ids> Regenerate specs for specific issues
dispatch --respec <glob> Regenerate specs matching a glob pattern
dispatch --spec "description" Generate a spec from an inline text description
dispatch --fix-tests [issue-id...] Run tests and fix failures via AI agent (optionally on specific issue branches)
Options:
--dry-run List tasks without dispatching (also works with --spec)
--no-plan Skip the planner agent, dispatch directly
--no-branch Skip branch creation, push, and PR lifecycle
--no-worktree Skip git worktree isolation for parallel issues
--feature [name] Group issues into a single feature branch and PR
--force Ignore prior run state and re-run all tasks
--concurrency <n> Max parallel dispatches (default: min(cpus, freeMB/500), max: ${MAX_CONCURRENCY})
--provider <name> Agent backend: ${PROVIDER_NAMES.join(", ")} (default: opencode)
--source <name> Issue source: ${DATASOURCE_NAMES.join(", ")} (optional; auto-detected from git remote)
--server-url <url> URL of a running provider server
--plan-timeout <min> Planning timeout in minutes (default: 30)
--retries <n> Retry attempts for all agents (default: 3)
--plan-retries <n> Retry attempts after planning timeout (overrides --retries for planner)
--test-timeout <min> Test timeout in minutes (default: 5)
--cwd <dir> Working directory (default: cwd)
Spec options:
--spec <value> Comma-separated issue numbers, glob pattern for .md files, or inline text description
--respec [value] Regenerate specs: issue numbers, glob, or omit to regenerate all existing specs
--spec-timeout <min> Spec generation timeout in minutes (default: 10)
--spec-warn-timeout <min> Spec warn-phase timeout in minutes (default: 10)
--spec-kill-timeout <min> Spec kill-phase timeout in minutes (default: 10)
--output-dir <dir> Output directory for specs (default: .dispatch/specs)
Azure DevOps options:
--org <url> Azure DevOps organization URL
--project <name> Azure DevOps project name
General:
--verbose Show detailed debug output for troubleshooting
-h, --help Show this help
-v, --version Show version
Interactive dispatch runs pause exhausted failed tasks so you can rerun them
in place; verbose or non-TTY runs do not wait for input.
Config:
dispatch config Launch interactive configuration wizard
Examples:
dispatch 14
dispatch 14,15,16
dispatch 14 15 16
dispatch
dispatch 14 --dry-run
dispatch 14 --provider copilot
dispatch --spec 42,43,44
dispatch --spec 42,43 --source github --provider copilot
dispatch --spec 100,200 --source azdevops --org https://dev.azure.com/myorg --project MyProject
dispatch --spec "drafts/*.md"
dispatch --spec "drafts/*.md" --source github
dispatch --spec "./my-feature.md" --provider copilot
dispatch --respec
dispatch --respec 42,43,44
dispatch --respec "specs/*.md"
dispatch --spec "add dark mode toggle to settings page"
dispatch --spec "feature A should do x" --provider copilot
dispatch --feature
dispatch --feature my-feature
dispatch --fix-tests
dispatch --fix-tests 14
dispatch --fix-tests 14 15 16
dispatch --fix-tests 14,15,16
dispatch config
`.trimStart();
/** Parsed CLI arguments including shell-only flags (help, version). */
export interface ParsedArgs extends Omit<RawCliArgs, "explicitFlags"> {
help: boolean;
version: boolean;
feature?: string | boolean;
}
/**
* Maps Commander option attribute names to their corresponding `explicitFlags`
* key names. This is the single source of truth for all CLI options.
*
* Keys are Commander's camelCase attribute names (derived from the flag string).
* Values are the flag names used in `explicitFlags` and downstream code.
*
* Exported so tests can verify help-text completeness.
*/
export const CLI_OPTIONS_MAP: Record<string, string> = {
help: "help",
version: "version",
dryRun: "dryRun",
plan: "noPlan",
branch: "noBranch",
worktree: "noWorktree",
force: "force",
verbose: "verbose",
spec: "spec",
respec: "respec",
fixTests: "fixTests",
feature: "feature",
source: "issueSource",
provider: "provider",
concurrency: "concurrency",
serverUrl: "serverUrl",
planTimeout: "planTimeout",
specTimeout: "specTimeout",
specWarnTimeout: "specWarnTimeout",
specKillTimeout: "specKillTimeout",
retries: "retries",
planRetries: "planRetries",
testTimeout: "testTimeout",
cwd: "cwd",
org: "org",
project: "project",
outputDir: "outputDir",
};
export function parseArgs(argv: string[]): [ParsedArgs, Set<string>] {
const program = new Command();
program
.exitOverride()
.configureOutput({
writeOut: () => {},
writeErr: () => {},
})
.helpOption(false)
.argument("[issueIds...]")
.option("-h, --help", "Show help")
.option("-v, --version", "Show version")
.option("--dry-run", "List tasks without dispatching")
.option("--no-plan", "Skip the planner agent")
.option("--no-branch", "Skip branch creation")
.option("--no-worktree", "Skip git worktree isolation")
.option("--feature [name]", "Group issues into a single feature branch")
.option("--force", "Ignore prior run state")
.option("--verbose", "Show detailed debug output")
.option("--fix-tests", "Run tests and fix failures (optionally pass issue IDs to target specific branches)")
.option("--spec <values...>", "Spec mode: issue numbers, glob, or text")
.option("--respec [values...]", "Regenerate specs")
.addOption(
new Option("--provider <name>", "Agent backend").choices(PROVIDER_NAMES),
)
.addOption(
new Option("--source <name>", "Issue source").choices(
[...DATASOURCE_NAMES],
),
)
.option(
"--concurrency <n>",
"Max parallel dispatches",
(val: string): number => {
const n = parseInt(val, 10);
if (isNaN(n) || n < 1) throw new CommanderError(1, "commander.invalidArgument", "--concurrency must be a positive integer");
if (n > MAX_CONCURRENCY) throw new CommanderError(1, "commander.invalidArgument", `--concurrency must not exceed ${MAX_CONCURRENCY}`);
return n;
},
)
.option(
"--plan-timeout <min>",
"Planning timeout in minutes",
(val: string): number => {
const n = parseFloat(val);
if (isNaN(n) || n < CONFIG_BOUNDS.planTimeout.min) throw new CommanderError(1, "commander.invalidArgument", "--plan-timeout must be a positive number (minutes)");
if (n > CONFIG_BOUNDS.planTimeout.max) throw new CommanderError(1, "commander.invalidArgument", `--plan-timeout must not exceed ${CONFIG_BOUNDS.planTimeout.max}`);
return n;
},
)
.option(
"--spec-timeout <min>",
"Spec generation timeout in minutes",
(val: string): number => {
const n = parseFloat(val);
if (isNaN(n) || n < CONFIG_BOUNDS.specTimeout.min) {
throw new CommanderError(1, "commander.invalidArgument", "--spec-timeout must be a positive number (minutes)");
}
if (n > CONFIG_BOUNDS.specTimeout.max) {
throw new CommanderError(1, "commander.invalidArgument", `--spec-timeout must not exceed ${CONFIG_BOUNDS.specTimeout.max}`);
}
return n;
},
)
.option(
"--spec-warn-timeout <min>",
"Spec warn-phase timeout in minutes",
(val: string): number => {
const n = parseFloat(val);
if (isNaN(n) || n < CONFIG_BOUNDS.specWarnTimeout.min) {
throw new CommanderError(1, "commander.invalidArgument", "--spec-warn-timeout must be a positive number (minutes)");
}
if (n > CONFIG_BOUNDS.specWarnTimeout.max) {
throw new CommanderError(1, "commander.invalidArgument", `--spec-warn-timeout must not exceed ${CONFIG_BOUNDS.specWarnTimeout.max}`);
}
return n;
},
)
.option(
"--spec-kill-timeout <min>",
"Spec kill-phase timeout in minutes",
(val: string): number => {
const n = parseFloat(val);
if (isNaN(n) || n < CONFIG_BOUNDS.specKillTimeout.min) {
throw new CommanderError(1, "commander.invalidArgument", "--spec-kill-timeout must be a positive number (minutes)");
}
if (n > CONFIG_BOUNDS.specKillTimeout.max) {
throw new CommanderError(1, "commander.invalidArgument", `--spec-kill-timeout must not exceed ${CONFIG_BOUNDS.specKillTimeout.max}`);
}
return n;
},
)
.option(
"--retries <n>",
"Retry attempts",
(val: string): number => {
const n = parseInt(val, 10);
if (isNaN(n) || n < 0) throw new CommanderError(1, "commander.invalidArgument", "--retries must be a non-negative integer");
return n;
},
)
.option(
"--plan-retries <n>",
"Planner retry attempts",
(val: string): number => {
const n = parseInt(val, 10);
if (isNaN(n) || n < 0) throw new CommanderError(1, "commander.invalidArgument", "--plan-retries must be a non-negative integer");
return n;
},
)
.option(
"--test-timeout <min>",
"Test timeout in minutes",
(val: string): number => {
const n = parseFloat(val);
if (isNaN(n) || n <= 0) throw new CommanderError(1, "commander.invalidArgument", "--test-timeout must be a positive number (minutes)");
return n;
},
)
.option("--cwd <dir>", "Working directory", (val: string) => resolve(val))
.option("--output-dir <dir>", "Output directory", (val: string) => resolve(val))
.option("--org <url>", "Azure DevOps organization URL")
.option("--project <name>", "Azure DevOps project name")
.option("--server-url <url>", "Provider server URL");
try {
program.parse(argv, { from: "user" });
} catch (err) {
if (err instanceof CommanderError) {
log.error(err.message);
process.exit(1);
}
throw err;
}
const opts = program.opts();
// ── Build ParsedArgs ────────────────────────────────────────
const args: ParsedArgs = {
issueIds: program.args,
dryRun: opts.dryRun ?? false,
noPlan: !opts.plan,
noBranch: !opts.branch,
noWorktree: !opts.worktree,
force: opts.force ?? false,
provider: opts.provider ?? "opencode",
cwd: opts.cwd ?? process.cwd(),
help: opts.help ?? false,
version: opts.version ?? false,
verbose: opts.verbose ?? false,
};
// Optional fields — only set when explicitly provided
if (opts.spec !== undefined) {
args.spec = opts.spec.length === 1 ? opts.spec[0] : opts.spec;
}
if (opts.respec !== undefined) {
if (opts.respec === true) {
args.respec = [];
} else {
args.respec = opts.respec.length === 1 ? opts.respec[0] : opts.respec;
}
}
if (opts.fixTests) args.fixTests = true;
if (opts.feature) args.feature = opts.feature;
if (opts.source !== undefined) args.issueSource = opts.source;
if (opts.concurrency !== undefined) args.concurrency = opts.concurrency;
if (opts.serverUrl !== undefined) args.serverUrl = opts.serverUrl;
if (opts.planTimeout !== undefined) args.planTimeout = opts.planTimeout;
if (opts.specTimeout !== undefined) args.specTimeout = opts.specTimeout;
if (opts.specWarnTimeout !== undefined) args.specWarnTimeout = opts.specWarnTimeout;
if (opts.specKillTimeout !== undefined) args.specKillTimeout = opts.specKillTimeout;
if (opts.retries !== undefined) args.retries = opts.retries;
if (opts.planRetries !== undefined) args.planRetries = opts.planRetries;
if (opts.testTimeout !== undefined) args.testTimeout = opts.testTimeout;
if (opts.org !== undefined) args.org = opts.org;
if (opts.project !== undefined) args.project = opts.project;
if (opts.outputDir !== undefined) args.outputDir = opts.outputDir;
// ── Derive explicitFlags from Commander option sources ─────
const explicitFlags = new Set<string>();
for (const [attr, flag] of Object.entries(CLI_OPTIONS_MAP)) {
if (program.getOptionValueSource(attr) === "cli") {
explicitFlags.add(flag);
}
}
return [args, explicitFlags];
}
async function main() {
const rawArgv = process.argv.slice(2);
// ── Config subcommand via Commander ────────────────────────
if (rawArgv[0] === "config") {
const configProgram = new Command("dispatch-config")
.exitOverride()
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
.helpOption(false)
.allowUnknownOption(true)
.allowExcessArguments(true)
.option("--cwd <dir>", "Working directory", (v: string) => resolve(v));
try {
configProgram.parse(rawArgv.slice(1), { from: "user" });
} catch (err) {
if (err instanceof CommanderError) {
log.error(err.message);
process.exit(1);
}
throw err;
}
const configDir = join(configProgram.opts().cwd ?? process.cwd(), ".dispatch");
await handleConfigCommand(rawArgv.slice(1), configDir);
process.exit(0);
}
// ── MCP subcommand ─────────────────────────────────────────
if (rawArgv[0] === "mcp") {
const mcpProgram = new Command("dispatch-mcp")
.exitOverride()
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
.helpOption(false)
.allowUnknownOption(true)
.allowExcessArguments(true)
.option("--port <number>", "Port to listen on", (v: string) => parseInt(v, 10), 9110)
.option("--host <host>", "Host to bind to", "127.0.0.1")
.option("--cwd <dir>", "Working directory", (v: string) => resolve(v));
try {
mcpProgram.parse(rawArgv.slice(1), { from: "user" });
} catch (err) {
if (err instanceof CommanderError) {
log.error(err.message);
process.exit(1);
}
throw err;
}
const mcpOpts = mcpProgram.opts<{ port: number; host: string; cwd?: string }>();
const { startMcpServer } = await import("./mcp/index.js");
await startMcpServer({
port: mcpOpts.port,
host: mcpOpts.host,
cwd: mcpOpts.cwd ?? process.cwd(),
});
// startMcpServer installs signal handlers and the http server keeps the
// event loop alive; we only reach here if something calls process.exit().
return;
}
const [args, explicitFlags] = parseArgs(rawArgv);
// Enable verbose logging before anything else
log.verbose = args.verbose;
// ── Graceful shutdown on signals ───────────────────────────
process.on("SIGINT", async () => {
log.debug("Received SIGINT, cleaning up...");
await runCleanup();
process.exit(130);
});
process.on("SIGTERM", async () => {
log.debug("Received SIGTERM, cleaning up...");
await runCleanup();
process.exit(143);
});
if (args.help) {
console.log(HELP);
process.exit(0);
}
if (args.version) {
console.log(`dispatch v${__VERSION__}`);
process.exit(0);
}
// ── Delegate to orchestrator ───────────────────────────────
const orchestrator = await bootOrchestrator({ cwd: args.cwd });
const { help: _, version: __, ...rawArgs } = args;
const summary = await orchestrator.runFromCli({ ...rawArgs, explicitFlags });
// Determine exit code from summary
const failed = "failed" in summary ? summary.failed : ("success" in summary && !summary.success ? 1 : 0);
process.exit(failed > 0 ? 1 : 0);
}
main().catch(async (err) => {
log.error(log.formatErrorChain(err));
await runCleanup();
process.exit(1);
});