@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 CHANGED
@@ -71,11 +71,12 @@ workbase/
71
71
  backend/
72
72
  ```
73
73
 
74
- Agency creates `AGENTS.md` and `.opencode/opencode.jsonc` during initialization
75
- and ensures they exist whenever the workbase is discovered. The OpenCode config
76
- grants external-directory access to task and epic references. Checksums in the
77
- generated files let newer Agency versions refresh unmodified content while
78
- preserving custom or edited files.
74
+ Agency keeps discovery and other observational commands read-only. Run
75
+ `agency integration status` to inspect `AGENTS.md` and
76
+ `.opencode/opencode.jsonc`, then `agency integration sync` to create missing
77
+ files or refresh checksum-safe managed files. The OpenCode config grants
78
+ external-directory access to task and epic references. Customized files are
79
+ reported but never overwritten.
79
80
 
80
81
  Repository metadata comes directly from Git under `repos/{alias}`. Workbase
81
82
  configuration may provide a custom writable-worktree creation command.
@@ -242,6 +243,8 @@ agency pr create refresh-copy
242
243
  agency init [path] [--json]
243
244
  agency workbase add <path> [--json]
244
245
  agency workbase list [--json]
246
+ agency integration status [--json]
247
+ agency integration sync [--json]
245
248
  agency repo add <alias> <remote> [--json]
246
249
  agency repo link <alias> <path> [--json]
247
250
  agency repo list [--json]
@@ -253,7 +256,8 @@ Registered workbases are stored in
253
256
  repository. Alias names are then used by all documents and commands.
254
257
 
255
258
  Commands that print Agency-owned results accept `--json`, including initialization,
256
- repository mutations, entity creation/list/show, status, validation, and PR creation.
259
+ integration inspection/sync, repository mutations, entity creation/list/show,
260
+ status, validation, and PR creation.
257
261
 
258
262
  ### Epics
259
263
 
@@ -270,7 +274,7 @@ back-reference.
270
274
  ### Tasks
271
275
 
272
276
  Create a task interactively. Text prompts identify optional values, and known
273
- choices use fzf:
277
+ choices use fzf. This command requires a TTY and fails with `--no-input`:
274
278
 
275
279
  ```text
276
280
  agency task new [id]
@@ -285,6 +289,8 @@ agency task create <id> --repo <alias>
285
289
  ```
286
290
 
287
291
  The branch defaults to `task/<id>` and the base defaults to `main`.
292
+ `task create` is always noninteractive and requires `--repo` for a single-phase
293
+ task. Use it instead of `task new` in scripts and agent workflows.
288
294
 
289
295
  Create a multi-phase task container:
290
296
 
@@ -293,6 +299,13 @@ agency task create <id> --multi-phase
293
299
  [--ticket-url <url>] [--description <text>] [--epic <id>] [--json]
294
300
  ```
295
301
 
302
+ ### Noninteractive Use
303
+
304
+ Agency never prompts when `--no-input` is set or stdin/stderr are not TTYs.
305
+ Commands with explicit inputs continue normally. `task new` fails immediately;
306
+ `work` requires an explicit directory, task ID, or `--epic` and must run from a
307
+ workbase; `validate` requires an explicit path or must run from a workbase.
308
+
296
309
  Inspect tasks:
297
310
 
298
311
  ```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"
@@ -13,6 +13,10 @@ import { epic, help as epicHelp } from "./src/commands/epic"
13
13
  import { phase, help as phaseHelp } from "./src/commands/phase"
14
14
  import { archive, help as archiveHelp } from "./src/commands/archive"
15
15
  import { workbase, help as workbaseHelp } from "./src/commands/workbase"
16
+ import {
17
+ integration,
18
+ help as integrationHelp,
19
+ } from "./src/commands/integration"
16
20
  import type { Command } from "./src/types"
17
21
  import { FileSystemService } from "./src/services/FileSystemService"
18
22
  import { WorkbaseService } from "./src/services/WorkbaseService"
@@ -23,6 +27,7 @@ import { PhaseService } from "./src/services/PhaseService"
23
27
  import { WorktreeService } from "./src/services/WorktreeService"
24
28
  import { PullRequestService } from "./src/services/PullRequestService"
25
29
  import { ArchiveService } from "./src/services/ArchiveService"
30
+ import { IntegrationService } from "./src/services/IntegrationService"
26
31
 
27
32
  // Create CLI layer with all services
28
33
  const CliLayer = Layer.mergeAll(
@@ -35,6 +40,7 @@ const CliLayer = Layer.mergeAll(
35
40
  WorktreeService.Default,
36
41
  PullRequestService.Default,
37
42
  ArchiveService.Default,
43
+ IntegrationService.Default,
38
44
  )
39
45
 
40
46
  /**
@@ -191,6 +197,22 @@ const commands: Record<string, Command> = {
191
197
  )
192
198
  },
193
199
  },
200
+ integration: {
201
+ run: async (args: string[], options: Record<string, any>) => {
202
+ if (options.help) {
203
+ console.log(integrationHelp)
204
+ return
205
+ }
206
+ await runCommand(
207
+ integration({
208
+ subcommand: args[0],
209
+ json: options.json,
210
+ silent: options.silent,
211
+ verbose: options.verbose,
212
+ }),
213
+ )
214
+ },
215
+ },
194
216
  repo: {
195
217
  run: async (args: string[], options: Record<string, any>) => {
196
218
  if (options.help) {
@@ -229,6 +251,7 @@ const commands: Record<string, Command> = {
229
251
  json: options.json,
230
252
  silent: options.silent,
231
253
  verbose: options.verbose,
254
+ inputAllowed: options.inputAllowed,
232
255
  }),
233
256
  )
234
257
  },
@@ -239,12 +262,6 @@ const commands: Record<string, Command> = {
239
262
  console.log(workHelp)
240
263
  return
241
264
  }
242
- if (args.length > 1) {
243
- throw new Error(
244
- "Usage: agency work [<directory-or-task-id> | --epic <epic-id>]",
245
- )
246
- }
247
-
248
265
  await runCommand(
249
266
  work({
250
267
  directory: args[0],
@@ -253,6 +270,7 @@ const commands: Record<string, Command> = {
253
270
  verbose: options.verbose,
254
271
  opencode: options.opencode,
255
272
  claude: options.claude,
273
+ inputAllowed: options.inputAllowed,
256
274
  }),
257
275
  )
258
276
  },
@@ -284,6 +302,7 @@ const commands: Record<string, Command> = {
284
302
  silent: options.silent,
285
303
  verbose: options.verbose,
286
304
  json: options.json,
305
+ inputAllowed: options.inputAllowed,
287
306
  }),
288
307
  )
289
308
  },
@@ -299,6 +318,7 @@ Usage: agency <command> [options]
299
318
  Commands:
300
319
  init [path] Initialize an Agency workbase
301
320
  workbase <subcommand> Manage registered workbases
321
+ integration <command> Inspect or sync managed integration files
302
322
  epic <subcommand> Manage epics
303
323
  phase <subcommand> Manage task phases
304
324
  archive <type> Archive a work item
@@ -314,6 +334,7 @@ Global Options:
314
334
  -V, --version Show version number
315
335
  -s, --silent Suppress output messages
316
336
  -v, --verbose Show verbose output including detailed debugging info
337
+ --no-input Never open an interactive prompt or selector
317
338
 
318
339
  Examples:
319
340
  agency init # Initialize the current directory
@@ -327,29 +348,7 @@ For more information about a command, run:
327
348
 
328
349
  try {
329
350
  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
- })
351
+ const { commandName, args: commandArgs, values } = parseCli(args)
353
352
 
354
353
  // Handle global flags
355
354
  if (values.version) {
@@ -358,80 +357,16 @@ try {
358
357
  }
359
358
 
360
359
  // Get command
361
- const commandName = positionals[0]
362
-
363
360
  // Show help if no command
364
361
  if (!commandName) {
365
362
  showMainHelp()
366
363
  process.exit(values.help ? 0 : 1)
367
364
  }
368
365
 
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)
366
+ const command = commands[commandName]!
367
+ const inputAllowed =
368
+ !values["no-input"] && Boolean(process.stdin.isTTY && process.stderr.isTTY)
369
+ await command.run(commandArgs, { ...values, inputAllowed })
435
370
  } catch (error) {
436
371
  if (error instanceof Error) {
437
372
  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.8.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -39,6 +39,7 @@ From anywhere beneath a workbase, run:
39
39
  ```bash
40
40
  agency status --json
41
41
  agency validate --json
42
+ agency integration status --json
42
43
  agency repo list --json
43
44
  agency epic list --json
44
45
  agency task list --json
@@ -56,6 +57,7 @@ If no workbase is found, do not initialize one without user intent. When asked:
56
57
 
57
58
  ```bash
58
59
  agency init [path]
60
+ agency integration sync
59
61
  ```
60
62
 
61
63
  Register known workbases so `agency work` can select one when run elsewhere:
@@ -108,7 +110,9 @@ Epic task ordering and dependencies live in `EPIC.md`. Creating a task with
108
110
  ## Create Single-Phase Tasks
109
111
 
110
112
  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.
113
+ for known choices, and allows optional inputs to be skipped. It requires a TTY
114
+ and fails when `--no-input` is set. `task create` never prompts and is the command
115
+ to use from agents and scripts.
112
116
 
113
117
  ```bash
114
118
  agency task create <id> \
@@ -241,6 +245,7 @@ validation errors before materializing worktrees or creating PRs. Validation
241
245
  checks schemas, aliases, backlinks, phase directories, duplicate references,
242
246
  duplicate writable branch ownership, unknown dependencies, and dependency cycles.
243
247
  Outside a workbase, omitting path opens the registered-workbase picker.
248
+ With `--no-input` or without a TTY, pass a path or run from a workbase instead.
244
249
 
245
250
  ## Worktrees And Agent Launch
246
251
 
@@ -256,6 +261,8 @@ replaces the current process with the selected agent. With no directory it opens
256
261
  an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
257
262
  directory, to infer the nearest epic, task, or phase.
258
263
  Outside a workbase, it first opens a picker containing registered workbases.
264
+ With `--no-input` or without a TTY, run from a workbase and provide an explicit
265
+ directory, task ID, or `--epic` so no picker is needed.
259
266
 
260
267
  Epic and multi-phase task targets are orchestration sessions launched beside
261
268
  their documents. Single-phase tasks and phases are execution sessions launched
@@ -0,0 +1,205 @@
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
+ [["integration", "status", "extra"], "agency integration status"],
86
+ [["integration", "sync", "extra"], "agency integration sync"],
87
+ [["repo", "add", "a", "b", "extra"], "agency repo add"],
88
+ [["repo", "link", "a", "b", "extra"], "agency repo link"],
89
+ [["repo", "list", "extra"], "agency repo list"],
90
+ [
91
+ [
92
+ "epic",
93
+ "create",
94
+ "one",
95
+ "two",
96
+ "--ticket-url",
97
+ "url",
98
+ "--repo",
99
+ "repo:main",
100
+ ],
101
+ "agency epic create",
102
+ ],
103
+ [["epic", "list", "extra"], "agency epic list"],
104
+ [["epic", "show", "one", "two"], "agency epic show"],
105
+ [["task", "new", "one", "two"], "agency task new"],
106
+ [
107
+ ["task", "create", "one", "two", "--repo", "repo"],
108
+ "agency task create",
109
+ ],
110
+ [["task", "list", "extra"], "agency task list"],
111
+ [["task", "show", "one", "two"], "agency task show"],
112
+ [["task", "status", "one", "open", "extra"], "agency task status"],
113
+ [
114
+ [
115
+ "phase",
116
+ "create",
117
+ "task",
118
+ "phase",
119
+ "extra",
120
+ "--repo",
121
+ "repo",
122
+ "--branch",
123
+ "branch",
124
+ "--base",
125
+ "main",
126
+ ],
127
+ "agency phase create",
128
+ ],
129
+ [["phase", "list", "one", "two"], "agency phase list"],
130
+ [["phase", "show", "one", "two", "three"], "agency phase show"],
131
+ [
132
+ ["phase", "status", "one", "two", "open", "extra"],
133
+ "agency phase status",
134
+ ],
135
+ [["archive", "epic", "one", "two"], "agency archive epic"],
136
+ [["archive", "task", "one", "two"], "agency archive task"],
137
+ [["archive", "phase", "one", "two", "three"], "agency archive phase"],
138
+ [["work", "one", "two"], "agency work"],
139
+ [["pr", "create", "one", "two", "three"], "agency pr create"],
140
+ [["status", "extra"], "agency status"],
141
+ [["validate", "one", "two"], "agency validate"],
142
+ ] as const) {
143
+ expectUsageError([...args], usage)
144
+ }
145
+ })
146
+
147
+ test("reports unknown subcommands with parent usage", () => {
148
+ expect(() => parseCli(["task", "crate"])).toThrow(
149
+ "Unknown subcommand 'crate'",
150
+ )
151
+ expectUsageError(
152
+ ["task", "crate"],
153
+ "agency task <new|create|list|show|status>",
154
+ )
155
+ })
156
+
157
+ test("rejects required-option omissions and explicit conflicts", () => {
158
+ expect(() => parseCli(["task", "create", "example"])).toThrow(
159
+ "--repo' is required",
160
+ )
161
+ for (const option of ["repo", "reference", "branch", "base"] as const) {
162
+ const value = option === "reference" ? "other:main" : "value"
163
+ expect(() =>
164
+ parseCli([
165
+ "task",
166
+ "create",
167
+ "example",
168
+ "--multi-phase",
169
+ `--${option}`,
170
+ value,
171
+ ]),
172
+ ).toThrow("cannot be combined")
173
+ }
174
+ expect(() =>
175
+ parseCli(["task", "new", "example", "--multi-phase", "--repo", "repo"]),
176
+ ).toThrow("cannot be combined")
177
+ expect(() => parseCli(["work", "--opencode", "--claude"])).toThrow(
178
+ "cannot be combined",
179
+ )
180
+ expect(() => parseCli(["work", "task", "--epic", "epic"])).toThrow(
181
+ "cannot be combined",
182
+ )
183
+ expect(() => parseCli(["status", "--silent", "--verbose"])).toThrow(
184
+ "cannot be combined",
185
+ )
186
+ })
187
+
188
+ test("accepts the global interaction control on every command", () => {
189
+ expect(parseCli(["--no-input", "task", "new"]).values["no-input"]).toBe(
190
+ true,
191
+ )
192
+ expect(parseCli(["work", "--no-input"]).values["no-input"]).toBe(true)
193
+ expect(parseCli(["validate", "--no-input"]).values["no-input"]).toBe(true)
194
+ expect(parseCli(["status", "--no-input"]).values["no-input"]).toBe(true)
195
+ expect(
196
+ parseCli(["task", "create", "example", "--repo", "repo", "--no-input"])
197
+ .values["no-input"],
198
+ ).toBe(true)
199
+ })
200
+
201
+ test("accepts grouped global short options before a command", () => {
202
+ const parsed = parseCli(["-sh", "task"])
203
+ expect(parsed.values).toMatchObject({ silent: true, help: true })
204
+ })
205
+ })