@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 CHANGED
@@ -270,7 +270,7 @@ back-reference.
270
270
  ### Tasks
271
271
 
272
272
  Create a task interactively. Text prompts identify optional values, and known
273
- choices use fzf:
273
+ choices use fzf. This command requires a TTY and fails with `--no-input`:
274
274
 
275
275
  ```text
276
276
  agency task new [id]
@@ -285,6 +285,8 @@ agency task create <id> --repo <alias>
285
285
  ```
286
286
 
287
287
  The branch defaults to `task/<id>` and the base defaults to `main`.
288
+ `task create` is always noninteractive and requires `--repo` for a single-phase
289
+ task. Use it instead of `task new` in scripts and agent workflows.
288
290
 
289
291
  Create a multi-phase task container:
290
292
 
@@ -293,6 +295,13 @@ agency task create <id> --multi-phase
293
295
  [--ticket-url <url>] [--description <text>] [--epic <id>] [--json]
294
296
  ```
295
297
 
298
+ ### Noninteractive Use
299
+
300
+ Agency never prompts when `--no-input` is set or stdin/stderr are not TTYs.
301
+ Commands with explicit inputs continue normally. `task new` fails immediately;
302
+ `work` requires an explicit directory, task ID, or `--epic` and must run from a
303
+ workbase; `validate` requires an explicit path or must run from a workbase.
304
+
296
305
  Inspect tasks:
297
306
 
298
307
  ```text
package/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { parseArgs } from "util"
4
3
  import { Effect, Either, Layer } from "effect"
4
+ import { parseCli } from "./src/cli-parser"
5
5
  import { init, help as initHelp } from "./src/commands/init"
6
6
  import { task, help as taskHelp } from "./src/commands/task"
7
7
  import { pr, help as prHelp } from "./src/commands/pr"
@@ -229,6 +229,7 @@ const commands: Record<string, Command> = {
229
229
  json: options.json,
230
230
  silent: options.silent,
231
231
  verbose: options.verbose,
232
+ inputAllowed: options.inputAllowed,
232
233
  }),
233
234
  )
234
235
  },
@@ -239,12 +240,6 @@ const commands: Record<string, Command> = {
239
240
  console.log(workHelp)
240
241
  return
241
242
  }
242
- if (args.length > 1) {
243
- throw new Error(
244
- "Usage: agency work [<directory-or-task-id> | --epic <epic-id>]",
245
- )
246
- }
247
-
248
243
  await runCommand(
249
244
  work({
250
245
  directory: args[0],
@@ -253,6 +248,7 @@ const commands: Record<string, Command> = {
253
248
  verbose: options.verbose,
254
249
  opencode: options.opencode,
255
250
  claude: options.claude,
251
+ inputAllowed: options.inputAllowed,
256
252
  }),
257
253
  )
258
254
  },
@@ -284,6 +280,7 @@ const commands: Record<string, Command> = {
284
280
  silent: options.silent,
285
281
  verbose: options.verbose,
286
282
  json: options.json,
283
+ inputAllowed: options.inputAllowed,
287
284
  }),
288
285
  )
289
286
  },
@@ -314,6 +311,7 @@ Global Options:
314
311
  -V, --version Show version number
315
312
  -s, --silent Suppress output messages
316
313
  -v, --verbose Show verbose output including detailed debugging info
314
+ --no-input Never open an interactive prompt or selector
317
315
 
318
316
  Examples:
319
317
  agency init # Initialize the current directory
@@ -327,29 +325,7 @@ For more information about a command, run:
327
325
 
328
326
  try {
329
327
  const args = process.argv.slice(2)
330
- const { values, positionals } = parseArgs({
331
- args,
332
- options: {
333
- help: {
334
- type: "boolean",
335
- short: "h",
336
- },
337
- version: {
338
- type: "boolean",
339
- short: "V",
340
- },
341
- silent: {
342
- type: "boolean",
343
- short: "s",
344
- },
345
- verbose: {
346
- type: "boolean",
347
- short: "v",
348
- },
349
- },
350
- strict: false,
351
- allowPositionals: true,
352
- })
328
+ const { commandName, args: commandArgs, values } = parseCli(args)
353
329
 
354
330
  // Handle global flags
355
331
  if (values.version) {
@@ -358,80 +334,16 @@ try {
358
334
  }
359
335
 
360
336
  // Get command
361
- const commandName = positionals[0]
362
-
363
337
  // Show help if no command
364
338
  if (!commandName) {
365
339
  showMainHelp()
366
340
  process.exit(values.help ? 0 : 1)
367
341
  }
368
342
 
369
- // Check if command exists
370
- const command = commands[commandName]
371
- if (!command) {
372
- console.error(`Error: Unknown command '${commandName}'`)
373
- console.error("\nRun 'agency --help' for usage information.")
374
- process.exit(1)
375
- }
376
-
377
- const commandIndex = args.indexOf(commandName)
378
- const commandArgs = [
379
- ...args.slice(0, commandIndex),
380
- ...args.slice(commandIndex + 1),
381
- ]
382
- const { values: cmdValues, positionals: cmdPositionals } = parseArgs({
383
- args: commandArgs,
384
- options: {
385
- help: {
386
- type: "boolean",
387
- short: "h",
388
- },
389
- silent: {
390
- type: "boolean",
391
- short: "s",
392
- },
393
- verbose: {
394
- type: "boolean",
395
- short: "v",
396
- },
397
- branch: {
398
- type: "string",
399
- },
400
- json: {
401
- type: "boolean",
402
- },
403
- "ticket-url": {
404
- type: "string",
405
- },
406
- description: {
407
- type: "string",
408
- },
409
- repo: {
410
- type: "string",
411
- multiple: true,
412
- },
413
- reference: {
414
- type: "string",
415
- multiple: true,
416
- },
417
- epic: { type: "string" },
418
- base: { type: "string" },
419
- "multi-phase": { type: "boolean" },
420
- "depends-on": { type: "string", multiple: true },
421
- "first-phase": { type: "string" },
422
- draft: { type: "boolean" },
423
- opencode: {
424
- type: "boolean",
425
- },
426
- claude: {
427
- type: "boolean",
428
- },
429
- },
430
- strict: false,
431
- allowPositionals: true,
432
- })
433
-
434
- await command.run(cmdPositionals, cmdValues)
343
+ const command = commands[commandName]!
344
+ const inputAllowed =
345
+ !values["no-input"] && Boolean(process.stdin.isTTY && process.stderr.isTTY)
346
+ await command.run(commandArgs, { ...values, inputAllowed })
435
347
  } catch (error) {
436
348
  if (error instanceof Error) {
437
349
  let message = error.message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.7.2",
3
+ "version": "2.7.3",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -108,7 +108,9 @@ Epic task ordering and dependencies live in `EPIC.md`. Creating a task with
108
108
  ## Create Single-Phase Tasks
109
109
 
110
110
  For guided creation, run `agency task new`. It prompts for text input, uses fzf
111
- for known choices, and allows optional inputs to be skipped.
111
+ for known choices, and allows optional inputs to be skipped. It requires a TTY
112
+ and fails when `--no-input` is set. `task create` never prompts and is the command
113
+ to use from agents and scripts.
112
114
 
113
115
  ```bash
114
116
  agency task create <id> \
@@ -241,6 +243,7 @@ validation errors before materializing worktrees or creating PRs. Validation
241
243
  checks schemas, aliases, backlinks, phase directories, duplicate references,
242
244
  duplicate writable branch ownership, unknown dependencies, and dependency cycles.
243
245
  Outside a workbase, omitting path opens the registered-workbase picker.
246
+ With `--no-input` or without a TTY, pass a path or run from a workbase instead.
244
247
 
245
248
  ## Worktrees And Agent Launch
246
249
 
@@ -256,6 +259,8 @@ replaces the current process with the selected agent. With no directory it opens
256
259
  an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
257
260
  directory, to infer the nearest epic, task, or phase.
258
261
  Outside a workbase, it first opens a picker containing registered workbases.
262
+ With `--no-input` or without a TTY, run from a workbase and provide an explicit
263
+ directory, task ID, or `--epic` so no picker is needed.
259
264
 
260
265
  Epic and multi-phase task targets are orchestration sessions launched beside
261
266
  their documents. Single-phase tasks and phases are execution sessions launched
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseCli } from "./cli-parser"
3
+
4
+ const expectUsageError = (args: string[], usage: string) => {
5
+ expect(() => parseCli(args)).toThrow(`Usage: ${usage}`)
6
+ }
7
+
8
+ describe("strict CLI parsing", () => {
9
+ test("rejects misspelled and command-inapplicable options", () => {
10
+ expectUsageError(["task", "list", "--josn"], "agency task")
11
+ expectUsageError(
12
+ ["task", "list", "--repo", "agency"],
13
+ "agency task list [--json]",
14
+ )
15
+ expectUsageError(["status", "--draft"], "agency status [--json]")
16
+ })
17
+
18
+ test("rejects duplicate scalar, boolean, and single-value multiple options", () => {
19
+ for (const args of [
20
+ ["status", "--json", "--json"],
21
+ ["task", "create", "example", "--repo", "one", "--repo", "two"],
22
+ [
23
+ "task",
24
+ "create",
25
+ "example",
26
+ "--repo",
27
+ "one",
28
+ "--base",
29
+ "a",
30
+ "--base",
31
+ "b",
32
+ ],
33
+ ["status", "-s", "--silent"],
34
+ ]) {
35
+ expect(() => parseCli(args)).toThrow("may only be specified once")
36
+ }
37
+ })
38
+
39
+ test("preserves explicitly repeatable options", () => {
40
+ expect(
41
+ parseCli([
42
+ "epic",
43
+ "create",
44
+ "delivery",
45
+ "--ticket-url",
46
+ "https://example.com",
47
+ "--repo",
48
+ "one:main",
49
+ "--repo",
50
+ "two:main",
51
+ ]).values.repo,
52
+ ).toEqual(["one:main", "two:main"])
53
+ expect(
54
+ parseCli([
55
+ "phase",
56
+ "create",
57
+ "task",
58
+ "phase",
59
+ "--repo",
60
+ "one",
61
+ "--branch",
62
+ "feature",
63
+ "--base",
64
+ "main",
65
+ "--reference",
66
+ "two:main",
67
+ "--reference",
68
+ "three:main",
69
+ "--depends-on",
70
+ "first",
71
+ "--depends-on",
72
+ "second",
73
+ ]).values,
74
+ ).toMatchObject({
75
+ reference: ["two:main", "three:main"],
76
+ "depends-on": ["first", "second"],
77
+ })
78
+ })
79
+
80
+ test("enforces exact maximum positional arity for every leaf command", () => {
81
+ for (const [args, usage] of [
82
+ [["init", "one", "two"], "agency init"],
83
+ [["workbase", "add", "one", "two"], "agency workbase add"],
84
+ [["workbase", "list", "extra"], "agency workbase list"],
85
+ [["repo", "add", "a", "b", "extra"], "agency repo add"],
86
+ [["repo", "link", "a", "b", "extra"], "agency repo link"],
87
+ [["repo", "list", "extra"], "agency repo list"],
88
+ [
89
+ [
90
+ "epic",
91
+ "create",
92
+ "one",
93
+ "two",
94
+ "--ticket-url",
95
+ "url",
96
+ "--repo",
97
+ "repo:main",
98
+ ],
99
+ "agency epic create",
100
+ ],
101
+ [["epic", "list", "extra"], "agency epic list"],
102
+ [["epic", "show", "one", "two"], "agency epic show"],
103
+ [["task", "new", "one", "two"], "agency task new"],
104
+ [
105
+ ["task", "create", "one", "two", "--repo", "repo"],
106
+ "agency task create",
107
+ ],
108
+ [["task", "list", "extra"], "agency task list"],
109
+ [["task", "show", "one", "two"], "agency task show"],
110
+ [["task", "status", "one", "open", "extra"], "agency task status"],
111
+ [
112
+ [
113
+ "phase",
114
+ "create",
115
+ "task",
116
+ "phase",
117
+ "extra",
118
+ "--repo",
119
+ "repo",
120
+ "--branch",
121
+ "branch",
122
+ "--base",
123
+ "main",
124
+ ],
125
+ "agency phase create",
126
+ ],
127
+ [["phase", "list", "one", "two"], "agency phase list"],
128
+ [["phase", "show", "one", "two", "three"], "agency phase show"],
129
+ [
130
+ ["phase", "status", "one", "two", "open", "extra"],
131
+ "agency phase status",
132
+ ],
133
+ [["archive", "epic", "one", "two"], "agency archive epic"],
134
+ [["archive", "task", "one", "two"], "agency archive task"],
135
+ [["archive", "phase", "one", "two", "three"], "agency archive phase"],
136
+ [["work", "one", "two"], "agency work"],
137
+ [["pr", "create", "one", "two", "three"], "agency pr create"],
138
+ [["status", "extra"], "agency status"],
139
+ [["validate", "one", "two"], "agency validate"],
140
+ ] as const) {
141
+ expectUsageError([...args], usage)
142
+ }
143
+ })
144
+
145
+ test("reports unknown subcommands with parent usage", () => {
146
+ expect(() => parseCli(["task", "crate"])).toThrow(
147
+ "Unknown subcommand 'crate'",
148
+ )
149
+ expectUsageError(
150
+ ["task", "crate"],
151
+ "agency task <new|create|list|show|status>",
152
+ )
153
+ })
154
+
155
+ test("rejects required-option omissions and explicit conflicts", () => {
156
+ expect(() => parseCli(["task", "create", "example"])).toThrow(
157
+ "--repo' is required",
158
+ )
159
+ for (const option of ["repo", "reference", "branch", "base"] as const) {
160
+ const value = option === "reference" ? "other:main" : "value"
161
+ expect(() =>
162
+ parseCli([
163
+ "task",
164
+ "create",
165
+ "example",
166
+ "--multi-phase",
167
+ `--${option}`,
168
+ value,
169
+ ]),
170
+ ).toThrow("cannot be combined")
171
+ }
172
+ expect(() =>
173
+ parseCli(["task", "new", "example", "--multi-phase", "--repo", "repo"]),
174
+ ).toThrow("cannot be combined")
175
+ expect(() => parseCli(["work", "--opencode", "--claude"])).toThrow(
176
+ "cannot be combined",
177
+ )
178
+ expect(() => parseCli(["work", "task", "--epic", "epic"])).toThrow(
179
+ "cannot be combined",
180
+ )
181
+ expect(() => parseCli(["status", "--silent", "--verbose"])).toThrow(
182
+ "cannot be combined",
183
+ )
184
+ })
185
+
186
+ test("accepts the global interaction control on every command", () => {
187
+ expect(parseCli(["--no-input", "task", "new"]).values["no-input"]).toBe(
188
+ true,
189
+ )
190
+ expect(parseCli(["work", "--no-input"]).values["no-input"]).toBe(true)
191
+ expect(parseCli(["validate", "--no-input"]).values["no-input"]).toBe(true)
192
+ expect(parseCli(["status", "--no-input"]).values["no-input"]).toBe(true)
193
+ expect(
194
+ parseCli(["task", "create", "example", "--repo", "repo", "--no-input"])
195
+ .values["no-input"],
196
+ ).toBe(true)
197
+ })
198
+
199
+ test("accepts grouped global short options before a command", () => {
200
+ const parsed = parseCli(["-sh", "task"])
201
+ expect(parsed.values).toMatchObject({ silent: true, help: true })
202
+ })
203
+ })