@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.
@@ -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("Error: Unknown command 'unknown'")
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")).text()).toContain(
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")).text(),
44
- ).toContain(`"${join(root, "tasks")}/*": "allow"`)
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 () => {