Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fb3bddd
refactor: inject command metadata and reject pagination cycles
thedogwiththedataonit Oct 11, 2026
60a3845
docs: place pagination and development notes beside their context
thedogwiththedataonit Oct 11, 2026
4d4cd72
fix: fence resumed lists and nested comment pagination
thedogwiththedataonit Oct 11, 2026
e5b6069
fix: confirm automation lifecycle changes and harden CLI output
thedogwiththedataonit Oct 11, 2026
bf96b13
fix: avoid confirmation prompts after stdin is consumed
thedogwiththedataonit Oct 11, 2026
7959972
Merge main for final pre-merge validation
thedogwiththedataonit Oct 12, 2026
29eaf27
fix(cli): preserve automation test payloads and confirm real delivery
thedogwiththedataonit Oct 11, 2026
2181f49
fix(cli): preserve stdin requests in confirmation reruns
thedogwiththedataonit Oct 11, 2026
02364a7
fix: complete and cancel type contract pagination safely
thedogwiththedataonit Oct 12, 2026
69598b0
fix(cli): classify stdin sends and enforce command deadlines
thedogwiththedataonit Oct 12, 2026
808dda1
fix(cli): preserve deadline budget across human confirmation
thedogwiththedataonit Oct 12, 2026
f2e5f9f
chore(api): sync integration consent provenance
thedogwiththedataonit Oct 12, 2026
ef725d6
fix: synchronize integration contact provenance contracts
thedogwiththedataonit Oct 12, 2026
ae0600d
fix(pagination): fail incomplete drains and explain sparse audience p…
thedogwiththedataonit Oct 12, 2026
ea20089
chore(api): document fail-closed inbox test creation
thedogwiththedataonit Oct 12, 2026
aa32c8c
Merge remote-tracking branch 'origin/main' into audit/cli-50
thedogwiththedataonit Oct 20, 2026
6a070d2
Merge remote-tracking branch 'origin/main' into audit/cli-47
thedogwiththedataonit Oct 20, 2026
206e46d
Merge remote-tracking branch 'origin/main' into audit/cli-48
thedogwiththedataonit Oct 20, 2026
e948a3b
fix: avoid confirmation prompts on consumed stdin
thedogwiththedataonit Oct 20, 2026
b8bd1ed
Merge branch 'audit/cli-47' into audit/cli-48
thedogwiththedataonit Oct 20, 2026
c400f2e
Merge branch 'audit/cli-47' into audit/cli-50
thedogwiththedataonit Oct 20, 2026
9526e0b
Merge remote-tracking branch 'origin/main' into audit/cli-50
thedogwiththedataonit Oct 20, 2026
2c2dd47
test: prove contract generation respects final-read interruption
thedogwiththedataonit Oct 20, 2026
3a3e337
Merge branch 'audit/cli-50' into audit/cli-48
thedogwiththedataonit Oct 20, 2026
2cc2cd2
docs: describe the prepared-input command deadline
thedogwiththedataonit Oct 20, 2026
e91eed5
Merge branch 'audit/cli-50' into audit/cli-48
thedogwiththedataonit Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ Designed for AI agents as first-class users:
pagination; `--idempotency-key` makes retries safe across process
restarts.

When `--all` receives a repeated or cyclic cursor, it exits with code 1 and
`CLI_PAGINATION` on stderr. It emits no truncated success payload. Error details
include the fetched page and item counts; a cyclic cursor is not offered as a
resume point. Empty pages with an advancing cursor continue normally.

## Command reference

The full generated reference lives in
Expand Down Expand Up @@ -142,6 +147,10 @@ keeps `main`'s email-audit and add-image blocks for the PR that ships them). Two
Contributor guide: [`AGENTS.md`](./AGENTS.md) · Release process:
[`RELEASING.md`](./RELEASING.md).

Development and release checks use Bun 1.3.10, pinned in every workflow.
Command metadata is supplied by CLI composition to `docs` and `doctor`; these
handlers do not import the live registry.

## License

MIT
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@
"typescript": "~6.0.2",
"vitest": "^4.1.2",
"yaml": "^2.8.0"
}
},
"packageManager": "bun@1.3.10"
}
18 changes: 13 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { BrewApiError } from '@brew.new/sdk'
import { Command, CommanderError } from 'commander'
import { buildRetryCommand, enforceConfirmation } from './lib/confirm'
import { makeContext, makeFallbackContext } from './lib/context'
import type { CommandSpec } from './lib/define-command'
import { GLOBAL_FLAGS } from './lib/define-command'
import type { CommandMetadata, CommandSpec } from './lib/define-command'
import { commandMetadata, GLOBAL_FLAGS } from './lib/define-command'
import {
CliApiError,
CliUsageError,
Expand Down Expand Up @@ -262,8 +262,9 @@ export function buildProgram(
for (const flag of GLOBAL_FLAGS) {
program.option(flag.flag, flag.summary)
}
const metadata = commands.map(commandMetadata)
for (const spec of commands) {
registerCommand(program, spec, io, rawArgv, onContext, onExitCode)
registerCommand(program, spec, io, rawArgv, onContext, onExitCode, metadata)
}
return program
}
Expand All @@ -274,7 +275,8 @@ function registerCommand(
io: CliIo,
rawArgv: readonly string[],
onContext?: (ctx: CliContext) => void,
onExitCode?: (code: number) => void
onExitCode?: (code: number) => void,
metadata: readonly CommandMetadata[] = []
): void {
const parent = resolveGroup(program, spec.path.slice(0, -1))
const leafName = spec.path.at(-1)
Expand Down Expand Up @@ -323,7 +325,13 @@ function registerCommand(
},
{}
)
const ctx = makeContext({ io, flags, rawArgv, traits: traitsOf(spec) })
const ctx = makeContext({
io,
flags,
rawArgv,
traits: traitsOf(spec),
commands: metadata,
})
onContext?.(ctx)
const invocation = { ctx, args: positional, flags }
await enforceConfirmation(spec, invocation)
Expand Down
4 changes: 2 additions & 2 deletions src/commands/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export const docsCommand = defineCommand({
},
],
examples: ['brew-cli docs', 'brew-cli docs --agent'],
run: async ({ flags }) => {
run: async ({ flags, ctx }) => {
if (flags.agent === true) {
return { data: buildManifest() }
return { data: buildManifest({ commands: ctx.commands }) }
}
return {
data: DOCS_POINTERS,
Expand Down
7 changes: 3 additions & 4 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { maskApiKey, resolveAuth } from '../lib/client'
import { defineCommand } from '../lib/define-command'
import { CliApiError } from '../lib/errors'
import type { CliContext } from '../lib/types'
import { ALL_COMMANDS } from '../registry'

/** Discovery endpoints without a dedicated command route. */
const NON_COMMAND_OPS = new Set(['GET /v1/llms.txt'])
Expand Down Expand Up @@ -74,7 +73,7 @@ export const doctorCommand = defineCommand({
.map((e) => `${e.method} ${e.path}`)
)
const localOps = new Set(
ALL_COMMANDS.flatMap((spec) =>
ctx.commands.flatMap((spec) =>
spec.route === undefined
? []
: [`${spec.route.method} ${spec.route.path}`]
Expand Down Expand Up @@ -109,7 +108,7 @@ export const doctorCommand = defineCommand({
brandId: auth.brandId ?? null,
surface: {
checked: isSurfaceChecked,
commands: ALL_COMMANDS.length,
commands: ctx.commands.length,
missingLocally,
extraLocally,
},
Expand All @@ -121,7 +120,7 @@ export const doctorCommand = defineCommand({
: '-- no API key (run `brew-cli login` or set BREW_API_KEY)',
isSurfaceChecked
? missingLocally.length === 0
? `ok surface in sync (${ALL_COMMANDS.length} commands cover the live API)`
? `ok surface in sync (${ctx.commands.length} commands cover the live API)`
: `DRIFT live API has ${missingLocally.length} operation(s) this CLI lacks — update @brew.new/cli:\n${missingLocally.map((op) => ` ${op}`).join('\n')}`
: '-- surface not checked (API unreachable)',
...(extraLocally.length > 0
Expand Down
3 changes: 3 additions & 0 deletions src/lib/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { BrewClient, SdkTransport } from './client'
import { buildSdkClient, resolveAuth } from './client'
import type { CommandMetadata } from './define-command'
import { CliTimeoutError } from './errors'
import { resolveOutputMode } from './output'
import {
Expand All @@ -23,6 +24,7 @@ export function makeContext(input: {
readonly flags: Readonly<Record<string, unknown>>
readonly rawArgv: readonly string[]
readonly traits?: CommandTraits
readonly commands?: readonly CommandMetadata[]
}): CliContext {
const globals = readGlobalFlags(input.flags)
const mode = resolveOutputMode({
Expand All @@ -39,6 +41,7 @@ export function makeContext(input: {
const transport = createTransportState()
const cache = new Map<string, BrewClient>()
const ctx: CliContext = {
commands: input.commands ?? [],
io: input.io,
mode,
globals,
Expand Down
37 changes: 37 additions & 0 deletions src/lib/define-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,43 @@ export function defineCommand(spec: CommandSpec): CommandSpec {
return spec
}

/** Read-only catalog data; discovery commands never need executable handlers. */
export type CommandMetadata = Pick<
CommandSpec,
| 'path'
| 'summary'
| 'sdkMethod'
| 'derivedFrom'
| 'route'
| 'commandClass'
| 'isCredited'
| 'isRawTransport'
| 'args'
| 'flags'
| 'examples'
> & { readonly confirmation: boolean }

export function commandMetadata(spec: CommandSpec): CommandMetadata {
return {
path: spec.path,
summary: spec.summary,
sdkMethod: spec.sdkMethod,
...(spec.derivedFrom !== undefined
? { derivedFrom: spec.derivedFrom }
: {}),
...(spec.route !== undefined ? { route: spec.route } : {}),
commandClass: spec.commandClass,
...(spec.isCredited !== undefined ? { isCredited: spec.isCredited } : {}),
...(spec.isRawTransport !== undefined
? { isRawTransport: spec.isRawTransport }
: {}),
...(spec.args !== undefined ? { args: spec.args } : {}),
...(spec.flags !== undefined ? { flags: spec.flags } : {}),
examples: spec.examples,
confirmation: spec.confirmSummary !== undefined,
}
}

export const GLOBAL_FLAGS: readonly FlagSpec[] = [
{
flag: '--json',
Expand Down
21 changes: 21 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ export const EXIT_INTERRUPTED = 130
/** 128 + SIGTERM, as a shell reports a terminated process. */
export const EXIT_TERMINATED = 143

/** A server pagination cycle is a failed drain, never a complete list. */
export class CliPaginationError extends Error {
readonly cursor: string

constructor(cursor: string) {
super('Pagination returned a previously fetched cursor.')
this.name = 'CliPaginationError'
this.cursor = cursor
}
}

export type InterruptSignal = 'SIGINT' | 'SIGTERM'

export function exitCodeForSignal(signal: InterruptSignal): number {
Expand Down Expand Up @@ -262,6 +273,16 @@ export function toErrorEnvelope(
}

function baseEnvelope(error: unknown, context: ErrorContext): CliErrorEnvelope {
if (error instanceof CliPaginationError) {
return {
code: 'CLI_PAGINATION',
type: 'internal_error',
message: error.message,
suggestion:
'Report the pagination failure. The list is incomplete; a cyclic cursor cannot safely resume it.',
details: { cursor: error.cursor },
}
}
if (error instanceof CliInterruptError) {
return {
code: 'CLI_INTERRUPTED',
Expand Down
13 changes: 8 additions & 5 deletions src/lib/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { ALL_COMMANDS } from '../registry'
import { SDK_SKIP_LIST, SPEC_SKIP_LIST } from '../skip-list'
import { CLI_NAME, CLI_VERSION } from '../version'
import { GLOBAL_FLAGS } from './define-command'
import { type CommandMetadata, GLOBAL_FLAGS } from './define-command'

/**
* The machine-readable surface contract printed by `brew-cli docs --agent`.
* Additive-only: tests snapshot this shape, so removals or renames are
* breaking changes by construction.
*/
export function buildManifest(): Record<string, unknown> {
export function buildManifest({
commands,
}: {
readonly commands: readonly CommandMetadata[]
}): Record<string, unknown> {
return {
name: CLI_NAME,
version: CLI_VERSION,
Expand Down Expand Up @@ -43,7 +46,7 @@ export function buildManifest(): Record<string, unknown> {
flag: flag.flag,
summary: flag.summary,
})),
commands: ALL_COMMANDS.map((spec) => ({
commands: commands.map((spec) => ({
command: [CLI_NAME, ...spec.path].join(' '),
summary: spec.summary,
class: spec.commandClass,
Expand All @@ -65,7 +68,7 @@ export function buildManifest(): Record<string, unknown> {
flag: flag.flag,
summary: flag.summary,
})),
confirmation: spec.confirmSummary !== undefined,
confirmation: spec.confirmation,
examples: spec.examples,
})),
skips: {
Expand Down
14 changes: 13 additions & 1 deletion src/lib/paginate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CliPaginationError } from './errors'
import { progress } from './output'
import type { CliContext } from './types'

Expand Down Expand Up @@ -25,6 +26,7 @@ export async function collectAll<T>(
const rows: T[] = []
let cursor: string | undefined
let pageCount = 0
const fetched = new Set<string>()
for (;;) {
let page: Page<T>
try {
Expand All @@ -42,10 +44,20 @@ export async function collectAll<T>(
}
rows.push(...page.data)
pageCount += 1
if (cursor !== undefined) {
fetched.add(cursor)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
const next = page.pagination?.cursor ?? null
if (next === null || next === cursor) {
if (next === null) {
return rows
}
if (fetched.has(next)) {
ctx.transport.setDrain({
rowsFetched: rows.length,
pagesFetched: pageCount,
})
throw new CliPaginationError(next)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
cursor = next
progress(ctx, `Fetched ${rows.length} rows (page ${pageCount})…`)
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BrewClient } from './client'
import type { CommandMetadata } from './define-command'
import type { Budget, TransportState } from './transport'

/**
Expand Down Expand Up @@ -59,6 +60,7 @@ export type ClientOptions = {
}

export type CliContext = {
readonly commands: readonly CommandMetadata[]
readonly io: CliIo
readonly mode: OutputMode
readonly globals: GlobalFlags
Expand Down
78 changes: 78 additions & 0 deletions tests/lib/paginate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { defineCommand } from '../../src/lib/define-command'
import { collectAll } from '../../src/lib/paginate'
import { runCli } from '../helpers/run-cli'

describe('pagination protocol', () => {
it.each([
['a', 'a'],
['a', 'b', 'a'],
])(
'fails a cyclic drain without printing partial data',
async (...cursors) => {
const requested: Array<string | undefined> = []
const command = defineCommand({
path: ['pagination-fixture'],
summary: 'Fixture',
sdkMethod: null,
commandClass: 'read',
examples: ['brew-cli pagination-fixture'],
run: async ({ ctx }) => ({
data: await collectAll(ctx, async (cursor) => {
requested.push(cursor)
if (requested.length > cursors.length) {
throw new Error('refetched a known page')
}
return {
data: [requested.length],
pagination: { cursor: cursors[requested.length - 1] ?? null },
}
}),
}),
})
const result = await runCli(['pagination-fixture', '--quiet'], {
extraCommands: [command],
})
expect(result.code).toBe(1)
expect(result.stdout).toBe('')
expect(JSON.parse(result.stderr)).toMatchObject({
error: {
code: 'CLI_PAGINATION',
progress: {
rowsFetched: cursors.length,
pagesFetched: cursors.length,
},
},
})
expect(requested).toEqual([undefined, ...cursors.slice(0, -1)])
expect(
JSON.parse(result.stderr).error.progress.resumeCursor
).toBeUndefined()
}
)

it('follows empty advancing pages and stops on a terminal or absent envelope', async () => {
const requested: Array<string | undefined> = []
const command = defineCommand({
path: ['pagination-fixture'],
summary: 'Fixture',
sdkMethod: null,
commandClass: 'read',
examples: ['brew-cli pagination-fixture'],
run: async ({ ctx }) => ({
data: await collectAll(ctx, async (cursor) => {
requested.push(cursor)
return cursor === undefined
? { data: [], pagination: { cursor: 'a' } }
: { data: [7] }
}),
}),
})
const result = await runCli(['pagination-fixture', '--quiet'], {
extraCommands: [command],
})
expect(result.code).toBe(0)
expect(result.json).toEqual([7])
expect(requested).toEqual([undefined, 'a'])
})
})
Loading
Loading