@markjaquith/agency 2.7.2 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/cli.ts +33 -98
- package/package.json +1 -1
- package/skills/agency/SKILL.md +8 -1
- package/src/cli-parser.test.ts +205 -0
- package/src/cli-parser.ts +573 -0
- package/src/cli.test.ts +49 -2
- package/src/commands/init.test.ts +3 -8
- package/src/commands/integration.test.ts +51 -0
- package/src/commands/integration.ts +62 -0
- package/src/commands/read-only.test.ts +144 -0
- 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/services/IntegrationService.test.ts +137 -0
- package/src/services/IntegrationService.ts +136 -0
- package/src/services/WorkbaseService.test.ts +4 -118
- package/src/services/WorkbaseService.ts +0 -53
- package/src/test-utils.ts +2 -0
- package/src/utils/command.ts +2 -0
- package/src/workbase/AGENTS.md +2 -0
- package/src/workbase/workbase-choice.ts +8 -0
|
@@ -0,0 +1,573 @@
|
|
|
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
|
+
integration: {
|
|
92
|
+
usage: "agency integration <status|sync>",
|
|
93
|
+
options: outputOptions,
|
|
94
|
+
subcommands: {
|
|
95
|
+
status: {
|
|
96
|
+
usage: "agency integration status [--json]",
|
|
97
|
+
minArgs: 0,
|
|
98
|
+
maxArgs: 0,
|
|
99
|
+
options: ["json"],
|
|
100
|
+
},
|
|
101
|
+
sync: {
|
|
102
|
+
usage: "agency integration sync [--json]",
|
|
103
|
+
minArgs: 0,
|
|
104
|
+
maxArgs: 0,
|
|
105
|
+
options: ["json"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
repo: {
|
|
110
|
+
usage: "agency repo <add|link|list>",
|
|
111
|
+
options: outputOptions,
|
|
112
|
+
subcommands: {
|
|
113
|
+
add: {
|
|
114
|
+
usage: "agency repo add <alias> <remote> [--json]",
|
|
115
|
+
minArgs: 2,
|
|
116
|
+
maxArgs: 2,
|
|
117
|
+
options: ["json"],
|
|
118
|
+
},
|
|
119
|
+
link: {
|
|
120
|
+
usage: "agency repo link <alias> <path> [--json]",
|
|
121
|
+
minArgs: 2,
|
|
122
|
+
maxArgs: 2,
|
|
123
|
+
options: ["json"],
|
|
124
|
+
},
|
|
125
|
+
list: {
|
|
126
|
+
usage: "agency repo list [--json]",
|
|
127
|
+
minArgs: 0,
|
|
128
|
+
maxArgs: 0,
|
|
129
|
+
options: ["json"],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
epic: {
|
|
134
|
+
usage: "agency epic <create|list|show>",
|
|
135
|
+
options: createOptions,
|
|
136
|
+
subcommands: {
|
|
137
|
+
create: {
|
|
138
|
+
usage:
|
|
139
|
+
"agency epic create <id> --ticket-url <url> --repo <alias>:<ref> [--repo <alias>:<ref>...]",
|
|
140
|
+
minArgs: 1,
|
|
141
|
+
maxArgs: 1,
|
|
142
|
+
options: ["ticket-url", "description", "repo", "json"],
|
|
143
|
+
required: ["ticket-url", "repo"],
|
|
144
|
+
repeatable: ["repo"],
|
|
145
|
+
},
|
|
146
|
+
list: {
|
|
147
|
+
usage: "agency epic list [--json]",
|
|
148
|
+
minArgs: 0,
|
|
149
|
+
maxArgs: 0,
|
|
150
|
+
options: ["json"],
|
|
151
|
+
},
|
|
152
|
+
show: {
|
|
153
|
+
usage: "agency epic show <id> [--json]",
|
|
154
|
+
minArgs: 1,
|
|
155
|
+
maxArgs: 1,
|
|
156
|
+
options: ["json"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
task: {
|
|
161
|
+
usage: "agency task <new|create|list|show|status>",
|
|
162
|
+
options: taskCreateOptions,
|
|
163
|
+
subcommands: {
|
|
164
|
+
new: {
|
|
165
|
+
usage: "agency task new [id] [options]",
|
|
166
|
+
minArgs: 0,
|
|
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
|
+
create: {
|
|
182
|
+
usage:
|
|
183
|
+
"agency task create <id> (--repo <alias> | --multi-phase) [options]",
|
|
184
|
+
minArgs: 1,
|
|
185
|
+
maxArgs: 1,
|
|
186
|
+
options: [
|
|
187
|
+
"ticket-url",
|
|
188
|
+
"description",
|
|
189
|
+
"epic",
|
|
190
|
+
"repo",
|
|
191
|
+
"reference",
|
|
192
|
+
"branch",
|
|
193
|
+
"base",
|
|
194
|
+
"multi-phase",
|
|
195
|
+
"json",
|
|
196
|
+
],
|
|
197
|
+
repeatable: ["reference"],
|
|
198
|
+
},
|
|
199
|
+
list: {
|
|
200
|
+
usage: "agency task list [--json]",
|
|
201
|
+
minArgs: 0,
|
|
202
|
+
maxArgs: 0,
|
|
203
|
+
options: ["json"],
|
|
204
|
+
},
|
|
205
|
+
show: {
|
|
206
|
+
usage: "agency task show <id> [--json]",
|
|
207
|
+
minArgs: 1,
|
|
208
|
+
maxArgs: 1,
|
|
209
|
+
options: ["json"],
|
|
210
|
+
},
|
|
211
|
+
status: {
|
|
212
|
+
usage: "agency task status <id> <status> [--json]",
|
|
213
|
+
minArgs: 2,
|
|
214
|
+
maxArgs: 2,
|
|
215
|
+
options: ["json"],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
phase: {
|
|
220
|
+
usage: "agency phase <create|list|show|status>",
|
|
221
|
+
options: phaseCreateOptions,
|
|
222
|
+
subcommands: {
|
|
223
|
+
create: {
|
|
224
|
+
usage:
|
|
225
|
+
"agency phase create <task-id> <phase-id> --repo <alias> --branch <name> --base <name> [options]",
|
|
226
|
+
minArgs: 2,
|
|
227
|
+
maxArgs: 2,
|
|
228
|
+
options: [
|
|
229
|
+
"description",
|
|
230
|
+
"repo",
|
|
231
|
+
"reference",
|
|
232
|
+
"branch",
|
|
233
|
+
"base",
|
|
234
|
+
"depends-on",
|
|
235
|
+
"first-phase",
|
|
236
|
+
"json",
|
|
237
|
+
],
|
|
238
|
+
required: ["repo", "branch", "base"],
|
|
239
|
+
repeatable: ["reference", "depends-on"],
|
|
240
|
+
},
|
|
241
|
+
list: {
|
|
242
|
+
usage: "agency phase list <task-id> [--json]",
|
|
243
|
+
minArgs: 1,
|
|
244
|
+
maxArgs: 1,
|
|
245
|
+
options: ["json"],
|
|
246
|
+
},
|
|
247
|
+
show: {
|
|
248
|
+
usage: "agency phase show <task-id> <phase-id> [--json]",
|
|
249
|
+
minArgs: 2,
|
|
250
|
+
maxArgs: 2,
|
|
251
|
+
options: ["json"],
|
|
252
|
+
},
|
|
253
|
+
status: {
|
|
254
|
+
usage: "agency phase status <task-id> <phase-id> <status> [--json]",
|
|
255
|
+
minArgs: 3,
|
|
256
|
+
maxArgs: 3,
|
|
257
|
+
options: ["json"],
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
archive: {
|
|
262
|
+
usage: "agency archive <epic|task|phase>",
|
|
263
|
+
options: outputOptions,
|
|
264
|
+
subcommands: {
|
|
265
|
+
epic: {
|
|
266
|
+
usage: "agency archive epic <epic-id> [--json]",
|
|
267
|
+
minArgs: 1,
|
|
268
|
+
maxArgs: 1,
|
|
269
|
+
options: ["json"],
|
|
270
|
+
},
|
|
271
|
+
task: {
|
|
272
|
+
usage: "agency archive task <task-id> [--json]",
|
|
273
|
+
minArgs: 1,
|
|
274
|
+
maxArgs: 1,
|
|
275
|
+
options: ["json"],
|
|
276
|
+
},
|
|
277
|
+
phase: {
|
|
278
|
+
usage: "agency archive phase <task-id> <phase-id> [--json]",
|
|
279
|
+
minArgs: 2,
|
|
280
|
+
maxArgs: 2,
|
|
281
|
+
options: ["json"],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
work: {
|
|
286
|
+
usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
|
|
287
|
+
options: {
|
|
288
|
+
...commonOptions,
|
|
289
|
+
epic: { type: "string" },
|
|
290
|
+
opencode: { type: "boolean" },
|
|
291
|
+
claude: { type: "boolean" },
|
|
292
|
+
},
|
|
293
|
+
command: {
|
|
294
|
+
usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
|
|
295
|
+
minArgs: 0,
|
|
296
|
+
maxArgs: 1,
|
|
297
|
+
options: ["epic", "opencode", "claude"],
|
|
298
|
+
conflicts: [
|
|
299
|
+
["opencode", "claude"],
|
|
300
|
+
["epic", "$positional"],
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
pr: {
|
|
305
|
+
usage: "agency pr create <task-id> [phase-id]",
|
|
306
|
+
options: {
|
|
307
|
+
...outputOptions,
|
|
308
|
+
draft: { type: "boolean" },
|
|
309
|
+
},
|
|
310
|
+
subcommands: {
|
|
311
|
+
create: {
|
|
312
|
+
usage: "agency pr create <task-id> [phase-id] [--draft] [--json]",
|
|
313
|
+
minArgs: 1,
|
|
314
|
+
maxArgs: 2,
|
|
315
|
+
options: ["draft", "json"],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
status: {
|
|
320
|
+
usage: "agency status [--json]",
|
|
321
|
+
options: outputOptions,
|
|
322
|
+
command: {
|
|
323
|
+
usage: "agency status [--json]",
|
|
324
|
+
minArgs: 0,
|
|
325
|
+
maxArgs: 0,
|
|
326
|
+
options: ["json"],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
validate: {
|
|
330
|
+
usage: "agency validate [path] [--json] [--no-input]",
|
|
331
|
+
options: {
|
|
332
|
+
...outputOptions,
|
|
333
|
+
},
|
|
334
|
+
command: {
|
|
335
|
+
usage: "agency validate [path] [--json] [--no-input]",
|
|
336
|
+
minArgs: 0,
|
|
337
|
+
maxArgs: 1,
|
|
338
|
+
options: ["json"],
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
} satisfies Readonly<Record<string, CommandDefinition>>
|
|
342
|
+
|
|
343
|
+
const rootOptions = commonOptions
|
|
344
|
+
const commonOptionNames = new Set(Object.keys(commonOptions))
|
|
345
|
+
const preCommandOptions = new Set([
|
|
346
|
+
"--help",
|
|
347
|
+
"-h",
|
|
348
|
+
"--version",
|
|
349
|
+
"-V",
|
|
350
|
+
"--silent",
|
|
351
|
+
"-s",
|
|
352
|
+
"--verbose",
|
|
353
|
+
"-v",
|
|
354
|
+
"--no-input",
|
|
355
|
+
])
|
|
356
|
+
|
|
357
|
+
export interface ParsedCli {
|
|
358
|
+
readonly commandName?: keyof typeof commands
|
|
359
|
+
readonly args: string[]
|
|
360
|
+
readonly values: Record<
|
|
361
|
+
string,
|
|
362
|
+
boolean | string | (boolean | string)[] | undefined
|
|
363
|
+
>
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const usageError = (message: string, usage: string) =>
|
|
367
|
+
new Error(`${message}\n\nUsage: ${usage}`)
|
|
368
|
+
|
|
369
|
+
const optionLabel = (name: string) => `--${name}`
|
|
370
|
+
|
|
371
|
+
function findCommandIndex(args: readonly string[]) {
|
|
372
|
+
for (const [index, argument] of args.entries()) {
|
|
373
|
+
if (!argument.startsWith("-")) return index
|
|
374
|
+
if (!preCommandOptions.has(argument) && !/^-[hVsv]+$/.test(argument)) {
|
|
375
|
+
throw usageError(
|
|
376
|
+
`Unknown option '${argument}'.`,
|
|
377
|
+
"agency <command> [options]",
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return -1
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function assertNoDuplicateOptions(
|
|
385
|
+
tokens: readonly { readonly kind: string; readonly name?: string }[],
|
|
386
|
+
repeatable: ReadonlySet<string>,
|
|
387
|
+
usage: string,
|
|
388
|
+
) {
|
|
389
|
+
const counts = new Map<string, number>()
|
|
390
|
+
for (const token of tokens) {
|
|
391
|
+
if (token.kind !== "option" || !token.name) continue
|
|
392
|
+
const count = (counts.get(token.name) ?? 0) + 1
|
|
393
|
+
counts.set(token.name, count)
|
|
394
|
+
if (count > 1 && !repeatable.has(token.name)) {
|
|
395
|
+
throw usageError(
|
|
396
|
+
`Option '${optionLabel(token.name)}' may only be specified once.`,
|
|
397
|
+
usage,
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parse(args: readonly string[], options: OptionConfig, usage: string) {
|
|
404
|
+
try {
|
|
405
|
+
return parseArgs({
|
|
406
|
+
args: [...args],
|
|
407
|
+
options,
|
|
408
|
+
strict: true,
|
|
409
|
+
allowPositionals: true,
|
|
410
|
+
tokens: true,
|
|
411
|
+
})
|
|
412
|
+
} catch (error) {
|
|
413
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
414
|
+
throw usageError(message, usage)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function validateTaskCreate(
|
|
419
|
+
values: ParsedCli["values"],
|
|
420
|
+
spec: LeafCommand,
|
|
421
|
+
requireRepo: boolean,
|
|
422
|
+
) {
|
|
423
|
+
if (values["multi-phase"]) {
|
|
424
|
+
for (const option of ["repo", "reference", "branch", "base"] as const) {
|
|
425
|
+
if (values[option] !== undefined) {
|
|
426
|
+
throw usageError(
|
|
427
|
+
`Option '--multi-phase' cannot be combined with '${optionLabel(option)}'.`,
|
|
428
|
+
spec.usage,
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else if (requireRepo && values.repo === undefined) {
|
|
433
|
+
throw usageError(
|
|
434
|
+
"Option '--repo' is required unless '--multi-phase' is used.",
|
|
435
|
+
spec.usage,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function parseCli(args: readonly string[]): ParsedCli {
|
|
441
|
+
const commandIndex = findCommandIndex(args)
|
|
442
|
+
if (commandIndex === -1) {
|
|
443
|
+
const parsed = parse(args, rootOptions, "agency <command> [options]")
|
|
444
|
+
assertNoDuplicateOptions(
|
|
445
|
+
parsed.tokens,
|
|
446
|
+
new Set(),
|
|
447
|
+
"agency <command> [options]",
|
|
448
|
+
)
|
|
449
|
+
if (parsed.values.silent && parsed.values.verbose) {
|
|
450
|
+
throw usageError(
|
|
451
|
+
"Options '--silent' and '--verbose' cannot be combined.",
|
|
452
|
+
"agency <command> [options]",
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
return { args: [], values: parsed.values }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const commandName = args[commandIndex]!
|
|
459
|
+
const definition: CommandDefinition | undefined =
|
|
460
|
+
commands[commandName as keyof typeof commands]
|
|
461
|
+
if (!definition) {
|
|
462
|
+
throw usageError(
|
|
463
|
+
`Unknown command '${commandName}'.`,
|
|
464
|
+
"agency <command> [options]",
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
const commandArgs = [
|
|
468
|
+
...args.slice(0, commandIndex),
|
|
469
|
+
...args.slice(commandIndex + 1),
|
|
470
|
+
]
|
|
471
|
+
const parsed = parse(commandArgs, definition.options, definition.usage)
|
|
472
|
+
const subcommand = definition.subcommands ? parsed.positionals[0] : undefined
|
|
473
|
+
if (definition.subcommands && !subcommand && parsed.values.help) {
|
|
474
|
+
for (const token of parsed.tokens) {
|
|
475
|
+
if (token.kind === "option" && !commonOptionNames.has(token.name)) {
|
|
476
|
+
throw usageError(
|
|
477
|
+
`Option '${optionLabel(token.name)}' is not valid for this command.`,
|
|
478
|
+
definition.usage,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
assertNoDuplicateOptions(parsed.tokens, new Set(), definition.usage)
|
|
483
|
+
return {
|
|
484
|
+
commandName: commandName as keyof typeof commands,
|
|
485
|
+
args: parsed.positionals,
|
|
486
|
+
values: parsed.values,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const spec = definition.subcommands
|
|
490
|
+
? definition.subcommands[subcommand ?? ""]
|
|
491
|
+
: definition.command
|
|
492
|
+
if (!spec) {
|
|
493
|
+
const message = subcommand
|
|
494
|
+
? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.`
|
|
495
|
+
: `A subcommand is required for 'agency ${commandName}'.`
|
|
496
|
+
throw usageError(message, definition.usage)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const commandPositionals = definition.subcommands
|
|
500
|
+
? parsed.positionals.slice(1)
|
|
501
|
+
: parsed.positionals
|
|
502
|
+
const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])])
|
|
503
|
+
for (const token of parsed.tokens) {
|
|
504
|
+
if (token.kind === "option" && !allowed.has(token.name)) {
|
|
505
|
+
throw usageError(
|
|
506
|
+
`Option '${optionLabel(token.name)}' is not valid for this command.`,
|
|
507
|
+
spec.usage,
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
assertNoDuplicateOptions(parsed.tokens, new Set(spec.repeatable), spec.usage)
|
|
512
|
+
|
|
513
|
+
if (parsed.values.silent && parsed.values.verbose) {
|
|
514
|
+
throw usageError(
|
|
515
|
+
"Options '--silent' and '--verbose' cannot be combined.",
|
|
516
|
+
spec.usage,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
if (parsed.values.version) {
|
|
520
|
+
return {
|
|
521
|
+
commandName: commandName as keyof typeof commands,
|
|
522
|
+
args: parsed.positionals,
|
|
523
|
+
values: parsed.values,
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (parsed.values.help) {
|
|
527
|
+
return {
|
|
528
|
+
commandName: commandName as keyof typeof commands,
|
|
529
|
+
args: parsed.positionals,
|
|
530
|
+
values: parsed.values,
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (
|
|
535
|
+
commandPositionals.length < spec.minArgs ||
|
|
536
|
+
commandPositionals.length > spec.maxArgs
|
|
537
|
+
) {
|
|
538
|
+
throw usageError(
|
|
539
|
+
`Expected ${spec.minArgs === spec.maxArgs ? spec.minArgs : `${spec.minArgs}-${spec.maxArgs}`} positional argument${spec.maxArgs === 1 ? "" : "s"}, received ${commandPositionals.length}.`,
|
|
540
|
+
spec.usage,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
for (const name of spec.required ?? []) {
|
|
544
|
+
if (parsed.values[name] === undefined) {
|
|
545
|
+
throw usageError(`Option '${optionLabel(name)}' is required.`, spec.usage)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const [left, right] of spec.conflicts ?? []) {
|
|
549
|
+
const leftSet = parsed.values[left] !== undefined
|
|
550
|
+
const rightSet =
|
|
551
|
+
right === "$positional"
|
|
552
|
+
? commandPositionals.length > 0
|
|
553
|
+
: parsed.values[right] !== undefined
|
|
554
|
+
if (leftSet && rightSet) {
|
|
555
|
+
throw usageError(
|
|
556
|
+
`Option '${optionLabel(left)}' cannot be combined with ${right === "$positional" ? "a positional argument" : `'${optionLabel(right)}'`}.`,
|
|
557
|
+
spec.usage,
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (
|
|
562
|
+
commandName === "task" &&
|
|
563
|
+
(subcommand === "new" || subcommand === "create")
|
|
564
|
+
) {
|
|
565
|
+
validateTaskCreate(parsed.values, spec, subcommand === "create")
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
commandName: commandName as keyof typeof commands,
|
|
570
|
+
args: parsed.positionals,
|
|
571
|
+
values: parsed.values,
|
|
572
|
+
}
|
|
573
|
+
}
|
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,10 +79,34 @@ 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"],
|
|
84
108
|
["workbase", "Usage: agency workbase"],
|
|
109
|
+
["integration", "Usage: agency integration"],
|
|
85
110
|
["repo", "Usage: agency repo"],
|
|
86
111
|
["epic", "Usage: agency epic"],
|
|
87
112
|
["task", "Usage: agency task"],
|
|
@@ -112,6 +137,28 @@ describe("CLI", () => {
|
|
|
112
137
|
expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
|
|
113
138
|
})
|
|
114
139
|
|
|
140
|
+
test("reports and synchronizes managed integration files", async () => {
|
|
141
|
+
const root = await createTempDir()
|
|
142
|
+
tempDirs.push(root)
|
|
143
|
+
expect((await runCli(["init", root])).exitCode).toBe(0)
|
|
144
|
+
|
|
145
|
+
const before = parseJson(
|
|
146
|
+
await runCli(["integration", "status", "--json"], root),
|
|
147
|
+
)
|
|
148
|
+
expect(before.files).toMatchObject([
|
|
149
|
+
{ name: "agents", state: "missing" },
|
|
150
|
+
{ name: "opencode", state: "missing" },
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
const synced = parseJson(
|
|
154
|
+
await runCli(["integration", "sync", "--json"], root),
|
|
155
|
+
)
|
|
156
|
+
expect(synced.files).toMatchObject([
|
|
157
|
+
{ name: "agents", state: "managed", changed: true },
|
|
158
|
+
{ name: "opencode", state: "managed", changed: true },
|
|
159
|
+
])
|
|
160
|
+
})
|
|
161
|
+
|
|
115
162
|
test("registers and lists workbases", async () => {
|
|
116
163
|
const parent = await createTempDir()
|
|
117
164
|
tempDirs.push(parent)
|
|
@@ -33,15 +33,10 @@ describe("init command", () => {
|
|
|
33
33
|
expect(await Bun.file(join(root, ".gitignore")).text()).toBe(
|
|
34
34
|
"/repos/\n/tasks/*/code/\n/tasks/*/phases/*/code/\n",
|
|
35
35
|
)
|
|
36
|
-
expect(await Bun.file(join(root, "AGENTS.md")).
|
|
37
|
-
"# Agency Workbase",
|
|
38
|
-
)
|
|
39
|
-
expect(
|
|
40
|
-
await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
|
|
41
|
-
).toContain('"path": "../tasks"')
|
|
36
|
+
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
42
37
|
expect(
|
|
43
|
-
await Bun.file(join(root, ".opencode/opencode.jsonc")).
|
|
44
|
-
).
|
|
38
|
+
await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
|
|
39
|
+
).toBe(false)
|
|
45
40
|
})
|
|
46
41
|
|
|
47
42
|
test("preserves existing gitignore entries", async () => {
|