@markjaquith/agency 2.7.2 → 2.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/cli.ts +10 -98
- package/package.json +1 -1
- package/skills/agency/SKILL.md +6 -1
- package/src/cli-parser.test.ts +203 -0
- package/src/cli-parser.ts +555 -0
- package/src/cli.test.ts +26 -2
- package/src/commands/task.test.ts +43 -0
- package/src/commands/task.ts +75 -38
- package/src/commands/validate.test.ts +28 -0
- package/src/commands/validate.ts +10 -1
- package/src/commands/work.test.ts +48 -0
- package/src/commands/work.ts +13 -1
- package/src/utils/command.ts +2 -0
- package/src/workbase/workbase-choice.ts +8 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { parseArgs, type ParseArgsConfig } from "node:util"
|
|
2
|
+
|
|
3
|
+
type OptionConfig = NonNullable<ParseArgsConfig["options"]>
|
|
4
|
+
|
|
5
|
+
interface LeafCommand {
|
|
6
|
+
readonly usage: string
|
|
7
|
+
readonly minArgs: number
|
|
8
|
+
readonly maxArgs: number
|
|
9
|
+
readonly options?: readonly string[]
|
|
10
|
+
readonly required?: readonly string[]
|
|
11
|
+
readonly repeatable?: readonly string[]
|
|
12
|
+
readonly conflicts?: readonly (readonly [string, string])[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CommandDefinition {
|
|
16
|
+
readonly usage: string
|
|
17
|
+
readonly options: OptionConfig
|
|
18
|
+
readonly command?: LeafCommand
|
|
19
|
+
readonly subcommands?: Readonly<Record<string, LeafCommand>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const commonOptions = {
|
|
23
|
+
help: { type: "boolean", short: "h" },
|
|
24
|
+
version: { type: "boolean", short: "V" },
|
|
25
|
+
silent: { type: "boolean", short: "s" },
|
|
26
|
+
verbose: { type: "boolean", short: "v" },
|
|
27
|
+
"no-input": { type: "boolean" },
|
|
28
|
+
} satisfies OptionConfig
|
|
29
|
+
|
|
30
|
+
const outputOptions = {
|
|
31
|
+
...commonOptions,
|
|
32
|
+
json: { type: "boolean" },
|
|
33
|
+
} satisfies OptionConfig
|
|
34
|
+
|
|
35
|
+
const createOptions = {
|
|
36
|
+
...outputOptions,
|
|
37
|
+
"ticket-url": { type: "string" },
|
|
38
|
+
description: { type: "string" },
|
|
39
|
+
repo: { type: "string", multiple: true },
|
|
40
|
+
} satisfies OptionConfig
|
|
41
|
+
|
|
42
|
+
const taskCreateOptions = {
|
|
43
|
+
...createOptions,
|
|
44
|
+
reference: { type: "string", multiple: true },
|
|
45
|
+
epic: { type: "string" },
|
|
46
|
+
branch: { type: "string" },
|
|
47
|
+
base: { type: "string" },
|
|
48
|
+
"multi-phase": { type: "boolean" },
|
|
49
|
+
} satisfies OptionConfig
|
|
50
|
+
|
|
51
|
+
const phaseCreateOptions = {
|
|
52
|
+
...outputOptions,
|
|
53
|
+
description: { type: "string" },
|
|
54
|
+
repo: { type: "string", multiple: true },
|
|
55
|
+
reference: { type: "string", multiple: true },
|
|
56
|
+
branch: { type: "string" },
|
|
57
|
+
base: { type: "string" },
|
|
58
|
+
"depends-on": { type: "string", multiple: true },
|
|
59
|
+
"first-phase": { type: "string" },
|
|
60
|
+
} satisfies OptionConfig
|
|
61
|
+
|
|
62
|
+
const commands = {
|
|
63
|
+
init: {
|
|
64
|
+
usage: "agency init [path] [--json]",
|
|
65
|
+
options: outputOptions,
|
|
66
|
+
command: {
|
|
67
|
+
usage: "agency init [path] [--json]",
|
|
68
|
+
minArgs: 0,
|
|
69
|
+
maxArgs: 1,
|
|
70
|
+
options: ["json"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
workbase: {
|
|
74
|
+
usage: "agency workbase <add|list>",
|
|
75
|
+
options: outputOptions,
|
|
76
|
+
subcommands: {
|
|
77
|
+
add: {
|
|
78
|
+
usage: "agency workbase add <path> [--json]",
|
|
79
|
+
minArgs: 1,
|
|
80
|
+
maxArgs: 1,
|
|
81
|
+
options: ["json"],
|
|
82
|
+
},
|
|
83
|
+
list: {
|
|
84
|
+
usage: "agency workbase list [--json]",
|
|
85
|
+
minArgs: 0,
|
|
86
|
+
maxArgs: 0,
|
|
87
|
+
options: ["json"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
repo: {
|
|
92
|
+
usage: "agency repo <add|link|list>",
|
|
93
|
+
options: outputOptions,
|
|
94
|
+
subcommands: {
|
|
95
|
+
add: {
|
|
96
|
+
usage: "agency repo add <alias> <remote> [--json]",
|
|
97
|
+
minArgs: 2,
|
|
98
|
+
maxArgs: 2,
|
|
99
|
+
options: ["json"],
|
|
100
|
+
},
|
|
101
|
+
link: {
|
|
102
|
+
usage: "agency repo link <alias> <path> [--json]",
|
|
103
|
+
minArgs: 2,
|
|
104
|
+
maxArgs: 2,
|
|
105
|
+
options: ["json"],
|
|
106
|
+
},
|
|
107
|
+
list: {
|
|
108
|
+
usage: "agency repo list [--json]",
|
|
109
|
+
minArgs: 0,
|
|
110
|
+
maxArgs: 0,
|
|
111
|
+
options: ["json"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
epic: {
|
|
116
|
+
usage: "agency epic <create|list|show>",
|
|
117
|
+
options: createOptions,
|
|
118
|
+
subcommands: {
|
|
119
|
+
create: {
|
|
120
|
+
usage:
|
|
121
|
+
"agency epic create <id> --ticket-url <url> --repo <alias>:<ref> [--repo <alias>:<ref>...]",
|
|
122
|
+
minArgs: 1,
|
|
123
|
+
maxArgs: 1,
|
|
124
|
+
options: ["ticket-url", "description", "repo", "json"],
|
|
125
|
+
required: ["ticket-url", "repo"],
|
|
126
|
+
repeatable: ["repo"],
|
|
127
|
+
},
|
|
128
|
+
list: {
|
|
129
|
+
usage: "agency epic list [--json]",
|
|
130
|
+
minArgs: 0,
|
|
131
|
+
maxArgs: 0,
|
|
132
|
+
options: ["json"],
|
|
133
|
+
},
|
|
134
|
+
show: {
|
|
135
|
+
usage: "agency epic show <id> [--json]",
|
|
136
|
+
minArgs: 1,
|
|
137
|
+
maxArgs: 1,
|
|
138
|
+
options: ["json"],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
task: {
|
|
143
|
+
usage: "agency task <new|create|list|show|status>",
|
|
144
|
+
options: taskCreateOptions,
|
|
145
|
+
subcommands: {
|
|
146
|
+
new: {
|
|
147
|
+
usage: "agency task new [id] [options]",
|
|
148
|
+
minArgs: 0,
|
|
149
|
+
maxArgs: 1,
|
|
150
|
+
options: [
|
|
151
|
+
"ticket-url",
|
|
152
|
+
"description",
|
|
153
|
+
"epic",
|
|
154
|
+
"repo",
|
|
155
|
+
"reference",
|
|
156
|
+
"branch",
|
|
157
|
+
"base",
|
|
158
|
+
"multi-phase",
|
|
159
|
+
"json",
|
|
160
|
+
],
|
|
161
|
+
repeatable: ["reference"],
|
|
162
|
+
},
|
|
163
|
+
create: {
|
|
164
|
+
usage:
|
|
165
|
+
"agency task create <id> (--repo <alias> | --multi-phase) [options]",
|
|
166
|
+
minArgs: 1,
|
|
167
|
+
maxArgs: 1,
|
|
168
|
+
options: [
|
|
169
|
+
"ticket-url",
|
|
170
|
+
"description",
|
|
171
|
+
"epic",
|
|
172
|
+
"repo",
|
|
173
|
+
"reference",
|
|
174
|
+
"branch",
|
|
175
|
+
"base",
|
|
176
|
+
"multi-phase",
|
|
177
|
+
"json",
|
|
178
|
+
],
|
|
179
|
+
repeatable: ["reference"],
|
|
180
|
+
},
|
|
181
|
+
list: {
|
|
182
|
+
usage: "agency task list [--json]",
|
|
183
|
+
minArgs: 0,
|
|
184
|
+
maxArgs: 0,
|
|
185
|
+
options: ["json"],
|
|
186
|
+
},
|
|
187
|
+
show: {
|
|
188
|
+
usage: "agency task show <id> [--json]",
|
|
189
|
+
minArgs: 1,
|
|
190
|
+
maxArgs: 1,
|
|
191
|
+
options: ["json"],
|
|
192
|
+
},
|
|
193
|
+
status: {
|
|
194
|
+
usage: "agency task status <id> <status> [--json]",
|
|
195
|
+
minArgs: 2,
|
|
196
|
+
maxArgs: 2,
|
|
197
|
+
options: ["json"],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
phase: {
|
|
202
|
+
usage: "agency phase <create|list|show|status>",
|
|
203
|
+
options: phaseCreateOptions,
|
|
204
|
+
subcommands: {
|
|
205
|
+
create: {
|
|
206
|
+
usage:
|
|
207
|
+
"agency phase create <task-id> <phase-id> --repo <alias> --branch <name> --base <name> [options]",
|
|
208
|
+
minArgs: 2,
|
|
209
|
+
maxArgs: 2,
|
|
210
|
+
options: [
|
|
211
|
+
"description",
|
|
212
|
+
"repo",
|
|
213
|
+
"reference",
|
|
214
|
+
"branch",
|
|
215
|
+
"base",
|
|
216
|
+
"depends-on",
|
|
217
|
+
"first-phase",
|
|
218
|
+
"json",
|
|
219
|
+
],
|
|
220
|
+
required: ["repo", "branch", "base"],
|
|
221
|
+
repeatable: ["reference", "depends-on"],
|
|
222
|
+
},
|
|
223
|
+
list: {
|
|
224
|
+
usage: "agency phase list <task-id> [--json]",
|
|
225
|
+
minArgs: 1,
|
|
226
|
+
maxArgs: 1,
|
|
227
|
+
options: ["json"],
|
|
228
|
+
},
|
|
229
|
+
show: {
|
|
230
|
+
usage: "agency phase show <task-id> <phase-id> [--json]",
|
|
231
|
+
minArgs: 2,
|
|
232
|
+
maxArgs: 2,
|
|
233
|
+
options: ["json"],
|
|
234
|
+
},
|
|
235
|
+
status: {
|
|
236
|
+
usage: "agency phase status <task-id> <phase-id> <status> [--json]",
|
|
237
|
+
minArgs: 3,
|
|
238
|
+
maxArgs: 3,
|
|
239
|
+
options: ["json"],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
archive: {
|
|
244
|
+
usage: "agency archive <epic|task|phase>",
|
|
245
|
+
options: outputOptions,
|
|
246
|
+
subcommands: {
|
|
247
|
+
epic: {
|
|
248
|
+
usage: "agency archive epic <epic-id> [--json]",
|
|
249
|
+
minArgs: 1,
|
|
250
|
+
maxArgs: 1,
|
|
251
|
+
options: ["json"],
|
|
252
|
+
},
|
|
253
|
+
task: {
|
|
254
|
+
usage: "agency archive task <task-id> [--json]",
|
|
255
|
+
minArgs: 1,
|
|
256
|
+
maxArgs: 1,
|
|
257
|
+
options: ["json"],
|
|
258
|
+
},
|
|
259
|
+
phase: {
|
|
260
|
+
usage: "agency archive phase <task-id> <phase-id> [--json]",
|
|
261
|
+
minArgs: 2,
|
|
262
|
+
maxArgs: 2,
|
|
263
|
+
options: ["json"],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
work: {
|
|
268
|
+
usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
|
|
269
|
+
options: {
|
|
270
|
+
...commonOptions,
|
|
271
|
+
epic: { type: "string" },
|
|
272
|
+
opencode: { type: "boolean" },
|
|
273
|
+
claude: { type: "boolean" },
|
|
274
|
+
},
|
|
275
|
+
command: {
|
|
276
|
+
usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
|
|
277
|
+
minArgs: 0,
|
|
278
|
+
maxArgs: 1,
|
|
279
|
+
options: ["epic", "opencode", "claude"],
|
|
280
|
+
conflicts: [
|
|
281
|
+
["opencode", "claude"],
|
|
282
|
+
["epic", "$positional"],
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
pr: {
|
|
287
|
+
usage: "agency pr create <task-id> [phase-id]",
|
|
288
|
+
options: {
|
|
289
|
+
...outputOptions,
|
|
290
|
+
draft: { type: "boolean" },
|
|
291
|
+
},
|
|
292
|
+
subcommands: {
|
|
293
|
+
create: {
|
|
294
|
+
usage: "agency pr create <task-id> [phase-id] [--draft] [--json]",
|
|
295
|
+
minArgs: 1,
|
|
296
|
+
maxArgs: 2,
|
|
297
|
+
options: ["draft", "json"],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
status: {
|
|
302
|
+
usage: "agency status [--json]",
|
|
303
|
+
options: outputOptions,
|
|
304
|
+
command: {
|
|
305
|
+
usage: "agency status [--json]",
|
|
306
|
+
minArgs: 0,
|
|
307
|
+
maxArgs: 0,
|
|
308
|
+
options: ["json"],
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
validate: {
|
|
312
|
+
usage: "agency validate [path] [--json] [--no-input]",
|
|
313
|
+
options: {
|
|
314
|
+
...outputOptions,
|
|
315
|
+
},
|
|
316
|
+
command: {
|
|
317
|
+
usage: "agency validate [path] [--json] [--no-input]",
|
|
318
|
+
minArgs: 0,
|
|
319
|
+
maxArgs: 1,
|
|
320
|
+
options: ["json"],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
} satisfies Readonly<Record<string, CommandDefinition>>
|
|
324
|
+
|
|
325
|
+
const rootOptions = commonOptions
|
|
326
|
+
const commonOptionNames = new Set(Object.keys(commonOptions))
|
|
327
|
+
const preCommandOptions = new Set([
|
|
328
|
+
"--help",
|
|
329
|
+
"-h",
|
|
330
|
+
"--version",
|
|
331
|
+
"-V",
|
|
332
|
+
"--silent",
|
|
333
|
+
"-s",
|
|
334
|
+
"--verbose",
|
|
335
|
+
"-v",
|
|
336
|
+
"--no-input",
|
|
337
|
+
])
|
|
338
|
+
|
|
339
|
+
export interface ParsedCli {
|
|
340
|
+
readonly commandName?: keyof typeof commands
|
|
341
|
+
readonly args: string[]
|
|
342
|
+
readonly values: Record<
|
|
343
|
+
string,
|
|
344
|
+
boolean | string | (boolean | string)[] | undefined
|
|
345
|
+
>
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const usageError = (message: string, usage: string) =>
|
|
349
|
+
new Error(`${message}\n\nUsage: ${usage}`)
|
|
350
|
+
|
|
351
|
+
const optionLabel = (name: string) => `--${name}`
|
|
352
|
+
|
|
353
|
+
function findCommandIndex(args: readonly string[]) {
|
|
354
|
+
for (const [index, argument] of args.entries()) {
|
|
355
|
+
if (!argument.startsWith("-")) return index
|
|
356
|
+
if (!preCommandOptions.has(argument) && !/^-[hVsv]+$/.test(argument)) {
|
|
357
|
+
throw usageError(
|
|
358
|
+
`Unknown option '${argument}'.`,
|
|
359
|
+
"agency <command> [options]",
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return -1
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function assertNoDuplicateOptions(
|
|
367
|
+
tokens: readonly { readonly kind: string; readonly name?: string }[],
|
|
368
|
+
repeatable: ReadonlySet<string>,
|
|
369
|
+
usage: string,
|
|
370
|
+
) {
|
|
371
|
+
const counts = new Map<string, number>()
|
|
372
|
+
for (const token of tokens) {
|
|
373
|
+
if (token.kind !== "option" || !token.name) continue
|
|
374
|
+
const count = (counts.get(token.name) ?? 0) + 1
|
|
375
|
+
counts.set(token.name, count)
|
|
376
|
+
if (count > 1 && !repeatable.has(token.name)) {
|
|
377
|
+
throw usageError(
|
|
378
|
+
`Option '${optionLabel(token.name)}' may only be specified once.`,
|
|
379
|
+
usage,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function parse(args: readonly string[], options: OptionConfig, usage: string) {
|
|
386
|
+
try {
|
|
387
|
+
return parseArgs({
|
|
388
|
+
args: [...args],
|
|
389
|
+
options,
|
|
390
|
+
strict: true,
|
|
391
|
+
allowPositionals: true,
|
|
392
|
+
tokens: true,
|
|
393
|
+
})
|
|
394
|
+
} catch (error) {
|
|
395
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
396
|
+
throw usageError(message, usage)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function validateTaskCreate(
|
|
401
|
+
values: ParsedCli["values"],
|
|
402
|
+
spec: LeafCommand,
|
|
403
|
+
requireRepo: boolean,
|
|
404
|
+
) {
|
|
405
|
+
if (values["multi-phase"]) {
|
|
406
|
+
for (const option of ["repo", "reference", "branch", "base"] as const) {
|
|
407
|
+
if (values[option] !== undefined) {
|
|
408
|
+
throw usageError(
|
|
409
|
+
`Option '--multi-phase' cannot be combined with '${optionLabel(option)}'.`,
|
|
410
|
+
spec.usage,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else if (requireRepo && values.repo === undefined) {
|
|
415
|
+
throw usageError(
|
|
416
|
+
"Option '--repo' is required unless '--multi-phase' is used.",
|
|
417
|
+
spec.usage,
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function parseCli(args: readonly string[]): ParsedCli {
|
|
423
|
+
const commandIndex = findCommandIndex(args)
|
|
424
|
+
if (commandIndex === -1) {
|
|
425
|
+
const parsed = parse(args, rootOptions, "agency <command> [options]")
|
|
426
|
+
assertNoDuplicateOptions(
|
|
427
|
+
parsed.tokens,
|
|
428
|
+
new Set(),
|
|
429
|
+
"agency <command> [options]",
|
|
430
|
+
)
|
|
431
|
+
if (parsed.values.silent && parsed.values.verbose) {
|
|
432
|
+
throw usageError(
|
|
433
|
+
"Options '--silent' and '--verbose' cannot be combined.",
|
|
434
|
+
"agency <command> [options]",
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
return { args: [], values: parsed.values }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const commandName = args[commandIndex]!
|
|
441
|
+
const definition: CommandDefinition | undefined =
|
|
442
|
+
commands[commandName as keyof typeof commands]
|
|
443
|
+
if (!definition) {
|
|
444
|
+
throw usageError(
|
|
445
|
+
`Unknown command '${commandName}'.`,
|
|
446
|
+
"agency <command> [options]",
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
const commandArgs = [
|
|
450
|
+
...args.slice(0, commandIndex),
|
|
451
|
+
...args.slice(commandIndex + 1),
|
|
452
|
+
]
|
|
453
|
+
const parsed = parse(commandArgs, definition.options, definition.usage)
|
|
454
|
+
const subcommand = definition.subcommands ? parsed.positionals[0] : undefined
|
|
455
|
+
if (definition.subcommands && !subcommand && parsed.values.help) {
|
|
456
|
+
for (const token of parsed.tokens) {
|
|
457
|
+
if (token.kind === "option" && !commonOptionNames.has(token.name)) {
|
|
458
|
+
throw usageError(
|
|
459
|
+
`Option '${optionLabel(token.name)}' is not valid for this command.`,
|
|
460
|
+
definition.usage,
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
assertNoDuplicateOptions(parsed.tokens, new Set(), definition.usage)
|
|
465
|
+
return {
|
|
466
|
+
commandName: commandName as keyof typeof commands,
|
|
467
|
+
args: parsed.positionals,
|
|
468
|
+
values: parsed.values,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const spec = definition.subcommands
|
|
472
|
+
? definition.subcommands[subcommand ?? ""]
|
|
473
|
+
: definition.command
|
|
474
|
+
if (!spec) {
|
|
475
|
+
const message = subcommand
|
|
476
|
+
? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.`
|
|
477
|
+
: `A subcommand is required for 'agency ${commandName}'.`
|
|
478
|
+
throw usageError(message, definition.usage)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const commandPositionals = definition.subcommands
|
|
482
|
+
? parsed.positionals.slice(1)
|
|
483
|
+
: parsed.positionals
|
|
484
|
+
const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])])
|
|
485
|
+
for (const token of parsed.tokens) {
|
|
486
|
+
if (token.kind === "option" && !allowed.has(token.name)) {
|
|
487
|
+
throw usageError(
|
|
488
|
+
`Option '${optionLabel(token.name)}' is not valid for this command.`,
|
|
489
|
+
spec.usage,
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
assertNoDuplicateOptions(parsed.tokens, new Set(spec.repeatable), spec.usage)
|
|
494
|
+
|
|
495
|
+
if (parsed.values.silent && parsed.values.verbose) {
|
|
496
|
+
throw usageError(
|
|
497
|
+
"Options '--silent' and '--verbose' cannot be combined.",
|
|
498
|
+
spec.usage,
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
if (parsed.values.version) {
|
|
502
|
+
return {
|
|
503
|
+
commandName: commandName as keyof typeof commands,
|
|
504
|
+
args: parsed.positionals,
|
|
505
|
+
values: parsed.values,
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (parsed.values.help) {
|
|
509
|
+
return {
|
|
510
|
+
commandName: commandName as keyof typeof commands,
|
|
511
|
+
args: parsed.positionals,
|
|
512
|
+
values: parsed.values,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (
|
|
517
|
+
commandPositionals.length < spec.minArgs ||
|
|
518
|
+
commandPositionals.length > spec.maxArgs
|
|
519
|
+
) {
|
|
520
|
+
throw usageError(
|
|
521
|
+
`Expected ${spec.minArgs === spec.maxArgs ? spec.minArgs : `${spec.minArgs}-${spec.maxArgs}`} positional argument${spec.maxArgs === 1 ? "" : "s"}, received ${commandPositionals.length}.`,
|
|
522
|
+
spec.usage,
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
for (const name of spec.required ?? []) {
|
|
526
|
+
if (parsed.values[name] === undefined) {
|
|
527
|
+
throw usageError(`Option '${optionLabel(name)}' is required.`, spec.usage)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const [left, right] of spec.conflicts ?? []) {
|
|
531
|
+
const leftSet = parsed.values[left] !== undefined
|
|
532
|
+
const rightSet =
|
|
533
|
+
right === "$positional"
|
|
534
|
+
? commandPositionals.length > 0
|
|
535
|
+
: parsed.values[right] !== undefined
|
|
536
|
+
if (leftSet && rightSet) {
|
|
537
|
+
throw usageError(
|
|
538
|
+
`Option '${optionLabel(left)}' cannot be combined with ${right === "$positional" ? "a positional argument" : `'${optionLabel(right)}'`}.`,
|
|
539
|
+
spec.usage,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (
|
|
544
|
+
commandName === "task" &&
|
|
545
|
+
(subcommand === "new" || subcommand === "create")
|
|
546
|
+
) {
|
|
547
|
+
validateTaskCreate(parsed.values, spec, subcommand === "create")
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
commandName: commandName as keyof typeof commands,
|
|
552
|
+
args: parsed.positionals,
|
|
553
|
+
values: parsed.values,
|
|
554
|
+
}
|
|
555
|
+
}
|
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { realpath } from "node:fs/promises"
|
|
2
|
+
import { access, realpath } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { cleanupTempDir, createTempDir } from "./test-utils"
|
|
5
5
|
|
|
@@ -67,7 +67,8 @@ describe("CLI", () => {
|
|
|
67
67
|
const unknown = await runCli(["unknown"])
|
|
68
68
|
expect(unknown.exitCode).toBe(1)
|
|
69
69
|
expect(unknown.stdout).toBe("")
|
|
70
|
-
expect(unknown.stderr).toContain("
|
|
70
|
+
expect(unknown.stderr).toContain("Unknown command 'unknown'")
|
|
71
|
+
expect(unknown.stderr).toContain("Usage: agency <command> [options]")
|
|
71
72
|
|
|
72
73
|
const cwd = await createTempDir()
|
|
73
74
|
tempDirs.push(cwd)
|
|
@@ -78,6 +79,29 @@ describe("CLI", () => {
|
|
|
78
79
|
expect(taggedError.stderr).not.toContain("An error has occurred")
|
|
79
80
|
})
|
|
80
81
|
|
|
82
|
+
test("rejects malformed input before running a command", async () => {
|
|
83
|
+
const parent = await createTempDir()
|
|
84
|
+
tempDirs.push(parent)
|
|
85
|
+
const root = join(parent, "workbase")
|
|
86
|
+
const result = await runCli(["init", root, "extra"])
|
|
87
|
+
|
|
88
|
+
expect(result.exitCode).toBe(1)
|
|
89
|
+
expect(result.stderr).toContain("Usage: agency init [path] [--json]")
|
|
90
|
+
await expect(access(root)).rejects.toThrow()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("refuses guided input without a TTY or with --no-input", async () => {
|
|
94
|
+
for (const args of [
|
|
95
|
+
["task", "new"],
|
|
96
|
+
["task", "new", "example", "--no-input"],
|
|
97
|
+
]) {
|
|
98
|
+
const result = await runCli(args)
|
|
99
|
+
expect(result.exitCode).toBe(1)
|
|
100
|
+
expect(result.stderr).toContain("task new requires interactive input")
|
|
101
|
+
expect(result.stderr).toContain("agency task create")
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
81
105
|
test("routes command help and global options on either side of commands", async () => {
|
|
82
106
|
for (const [command, usage] of [
|
|
83
107
|
["init", "Usage: agency init"],
|
|
@@ -91,4 +91,47 @@ describe("task creation input", () => {
|
|
|
91
91
|
expect(content).toContain("branch: task/scripted-task")
|
|
92
92
|
expect(content).toContain("base: main")
|
|
93
93
|
})
|
|
94
|
+
|
|
95
|
+
test("never prompts when scripted creation is incomplete", async () => {
|
|
96
|
+
const interaction: TaskInteraction = {
|
|
97
|
+
text: () => Effect.fail(new Error("unexpected text prompt")),
|
|
98
|
+
select: () => Effect.fail(new Error("unexpected selection prompt")),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
runTestEffect(
|
|
103
|
+
task(
|
|
104
|
+
{
|
|
105
|
+
subcommand: "create",
|
|
106
|
+
args: ["scripted-task"],
|
|
107
|
+
cwd: root,
|
|
108
|
+
silent: true,
|
|
109
|
+
},
|
|
110
|
+
interaction,
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
).rejects.toThrow("Writable repository is required")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("refuses guided creation when input is disabled", async () => {
|
|
117
|
+
const interaction: TaskInteraction = {
|
|
118
|
+
text: () => Effect.fail(new Error("unexpected text prompt")),
|
|
119
|
+
select: () => Effect.fail(new Error("unexpected selection prompt")),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
runTestEffect(
|
|
124
|
+
task(
|
|
125
|
+
{
|
|
126
|
+
subcommand: "new",
|
|
127
|
+
args: [],
|
|
128
|
+
cwd: root,
|
|
129
|
+
silent: true,
|
|
130
|
+
inputAllowed: false,
|
|
131
|
+
},
|
|
132
|
+
interaction,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
).rejects.toThrow("task new requires interactive input")
|
|
136
|
+
})
|
|
94
137
|
})
|