diff --git a/src/adapters/commander/commander-cli-adapter.test.ts b/src/adapters/commander/commander-cli-adapter.test.ts index 3aef34d3..d171dfbc 100644 --- a/src/adapters/commander/commander-cli-adapter.test.ts +++ b/src/adapters/commander/commander-cli-adapter.test.ts @@ -7,6 +7,7 @@ import { createNoopFileUploadStore, createTextBuffer, } from "../../../__tests__/helpers.ts"; +import { JSON_OUTPUT_SCHEMA_VERSION } from "../../application/commands/command-output.ts"; import { createTranslator } from "../../i18n/translator.ts"; import { CommanderCliAdapter } from "./commander-cli-adapter.ts"; @@ -133,6 +134,127 @@ describe("CommanderCliAdapter", () => { ]); }); + test("attaches the shared output options and hands the handler a working output handle", async () => { + const adapter = new CommanderCliAdapter(); + const stdout = createTextBuffer(); + const stderr = createTextBuffer(); + const observedFormats: string[] = []; + const catalog: CliCatalog = { + commands: [ + { + handler: (_input, context) => { + observedFormats.push(context.output.format); + context.output.emit({ ok: true }, () => { + context.stdout.write("text\n"); + }); + }, + inputSchema: z.object({}), + name: "demo", + output: "standard", + summaryKey: "commands.help.summary", + }, + ], + descriptionKey: "app.description", + globalOptions: [], + name: "oo", + }; + + const exitCode = await adapter.run({ + argv: ["demo", "--json", "--show-schema-version"], + catalog, + context: createCommanderContext(catalog, stdout.writer, stderr.writer), + }); + + expect(exitCode).toBe(0); + expect(observedFormats).toEqual(["json"]); + expect(JSON.parse(stdout.read())).toEqual({ + ok: true, + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + }); + }); + + test("rejects an invalid --format value before the handler runs", async () => { + const adapter = new CommanderCliAdapter(); + const stdout = createTextBuffer(); + const stderr = createTextBuffer(); + let handlerRan = false; + const failedEvents: Array<{ errorKey?: string; exitCode: number }> = []; + const catalog: CliCatalog = { + commands: [ + { + handler: () => { + handlerRan = true; + }, + inputSchema: z.object({}), + name: "demo", + output: "standard", + summaryKey: "commands.help.summary", + }, + ], + descriptionKey: "app.description", + globalOptions: [], + name: "oo", + }; + + const exitCode = await adapter.run({ + argv: ["demo", "--format", "yaml"], + catalog, + context: createCommanderContext(catalog, stdout.writer, stderr.writer), + observer: { + onCommandFailed: (event) => { + failedEvents.push(event); + }, + }, + }); + + expect(exitCode).toBe(2); + expect(handlerRan).toBe(false); + expect(failedEvents).toEqual([ + { + errorKey: "errors.shared.invalidFormat", + exitCode: 2, + }, + ]); + }); + + test("reports json output format for json-only commands without flags", async () => { + const adapter = new CommanderCliAdapter(); + const stdout = createTextBuffer(); + const stderr = createTextBuffer(); + const resolvedFormats: string[] = []; + const catalog: CliCatalog = { + commands: [ + { + handler: (_input, context) => { + context.output.emitJson({ ok: true }); + }, + inputSchema: z.object({}), + name: "demo", + output: "json-only", + summaryKey: "commands.help.summary", + }, + ], + descriptionKey: "app.description", + globalOptions: [], + name: "oo", + }; + + const exitCode = await adapter.run({ + argv: ["demo"], + catalog, + context: createCommanderContext(catalog, stdout.writer, stderr.writer), + observer: { + onCommandResolved: (event) => { + resolvedFormats.push(event.outputFormat); + }, + }, + }); + + expect(exitCode).toBe(0); + expect(resolvedFormats).toEqual(["json"]); + expect(stdout.read()).toBe("{\"ok\":true}\n"); + }); + test("maps option alias flags to the primary option name", async () => { const adapter = new CommanderCliAdapter(); const stdout = createTextBuffer(); diff --git a/src/adapters/commander/commander-cli-adapter.ts b/src/adapters/commander/commander-cli-adapter.ts index 18b568e1..2d0b1225 100644 --- a/src/adapters/commander/commander-cli-adapter.ts +++ b/src/adapters/commander/commander-cli-adapter.ts @@ -16,6 +16,11 @@ import { CommanderError, Option, } from "commander"; +import { + createCommandOutput, + outputFormatOptions, + resolveOutputFormat, +} from "../../application/commands/command-output.ts"; import { CliUserError, } from "../../application/contracts/cli.ts"; @@ -238,7 +243,11 @@ function configureCommand( ); } - for (const option of definition.options ?? []) { + const options = definition.output === undefined + ? definition.options ?? [] + : [...definition.options ?? [], ...outputFormatOptions]; + + for (const option of options) { for (const commanderOption of createOptions(option, translator)) { command.addOption(commanderOption); } @@ -277,16 +286,24 @@ function bindCommandHandler( commandPath: resolveCommandPath(commandInstance), excludeFromTelemetry: definition.excludeFromTelemetry === true, flagsCount: countFlagArguments(request.argv), - outputFormat: resolveOutputFormat(optionValues), + outputFormat: definition.output === "json-only" + ? "json" + : resolveOutputFormat(optionValues), }); + const output = createCommandOutput( + request.context.stdout, + optionValues, + definition.output, + ); + const parsedInput = parseInput( definition, inputSchema, rawInput, ); - await handler(parsedInput, request.context); + await handler(parsedInput, { ...request.context, output }); request.observer?.onCommandCompleted?.({ exitCode: 0 }); }); } @@ -561,12 +578,6 @@ function countFlagArguments(argv: readonly string[]): number { return argv.filter(argument => argument.startsWith("-") && argument !== "--").length; } -function resolveOutputFormat(optionValues: OptionValues): "json" | "text" { - return optionValues.format === "json" || optionValues.json === true - ? "json" - : "text"; -} - function resolveParseErrorKind( error: CommanderError, ): CliParseErrorKind | undefined { diff --git a/src/application/commands/auth/index.cli.test.ts b/src/application/commands/auth/index.cli.test.ts index 52af8b0e..16975161 100644 --- a/src/application/commands/auth/index.cli.test.ts +++ b/src/application/commands/auth/index.cli.test.ts @@ -27,7 +27,7 @@ import { readTelemetryRowsForTest, } from "../../telemetry/outbox.ts"; import { createTerminalColors } from "../../terminal-colors.ts"; -import { JSON_OUTPUT_SCHEMA_VERSION } from "../json-output.ts"; +import { JSON_OUTPUT_SCHEMA_VERSION } from "../command-output.ts"; const loginUrlColor = "#c09ff5"; diff --git a/src/application/commands/auth/status.ts b/src/application/commands/auth/status.ts index f7095579..04aaeacd 100644 --- a/src/application/commands/auth/status.ts +++ b/src/application/commands/auth/status.ts @@ -15,7 +15,7 @@ import { resolveIdentity } from "../../auth/identity.ts"; import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { probeOo } from "../shared/oo-request.ts"; import { writeLine } from "../shared/output.ts"; @@ -133,7 +133,7 @@ export const authStatusCommand: CliCommandDefinition = { aliases: ["info"], summaryKey: "commands.auth.status.summary", descriptionKey: "commands.auth.status.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(authStatusFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/check-update.ts b/src/application/commands/check-update.ts index 9a049dff..79636d5a 100644 --- a/src/application/commands/check-update.ts +++ b/src/application/commands/check-update.ts @@ -9,7 +9,7 @@ import { cliUpdateCommand, renderCliUpdateNotice, } from "../update/update-notifier.ts"; -import { jsonOutputOptions, writeJsonOutput } from "./json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "./command-output.ts"; import { classifyTelemetryVersionKind } from "./self-update-telemetry.ts"; import { createFormatInputError } from "./shared/input-parsing.ts"; @@ -40,7 +40,7 @@ export const checkUpdateCommand: CliCommandDefinition = { name: "check-update", summaryKey: "commands.checkUpdate.summary", descriptionKey: "commands.checkUpdate.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(checkUpdateFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/command-output.test.ts b/src/application/commands/command-output.test.ts new file mode 100644 index 00000000..7f58cc48 --- /dev/null +++ b/src/application/commands/command-output.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, test } from "bun:test"; + +import { CliUserError } from "../contracts/cli.ts"; +import { + createCommandOutput, + JSON_OUTPUT_SCHEMA_VERSION, + resolveOutputFormat, + writeJsonOutput, +} from "./command-output.ts"; + +describe("resolveOutputFormat", () => { + test.each([ + { expected: "text", optionValues: {}, title: "no options" }, + { expected: "json", optionValues: { format: "json" }, title: "--format json" }, + { expected: "json", optionValues: { json: true }, title: "--json" }, + { + expected: "json", + optionValues: { format: "yaml", json: true }, + title: "--json wins over an invalid --format value", + }, + { + expected: "text", + optionValues: { format: "yaml" }, + title: "an invalid --format value resolves leniently to text", + }, + { expected: "text", optionValues: { format: "" }, title: "an empty --format value" }, + ])("$title -> $expected", ({ expected, optionValues }) => { + expect(resolveOutputFormat(optionValues)).toBe(expected as "json" | "text"); + }); +}); + +describe("createCommandOutput", () => { + describe("without an output mode (inert handle)", () => { + test("resolves to text and never validates", () => { + const output = createCommandOutput( + createCollectingWriter([]), + { format: "yaml", json: true }, + undefined, + ); + + expect(output.format).toBe("text"); + }); + }); + + describe("standard mode", () => { + test.each([ + { expected: "text", optionValues: {}, title: "defaults to text" }, + { expected: "json", optionValues: { format: "json" }, title: "--format json" }, + { expected: "json", optionValues: { json: true }, title: "--json" }, + ])("$title -> $expected", ({ expected, optionValues }) => { + const output = createCommandOutput( + createCollectingWriter([]), + optionValues, + "standard", + ); + + expect(output.format).toBe(expected as "json" | "text"); + }); + + test("rejects an invalid --format value with the shared error", () => { + expect(() => createCommandOutput( + createCollectingWriter([]), + { format: "yaml" }, + "standard", + )).toThrow(CliUserError); + + try { + createCommandOutput(createCollectingWriter([]), { format: "yaml" }, "standard"); + } + catch (error) { + expect(error).toBeInstanceOf(CliUserError); + expect((error as CliUserError).key).toBe("errors.shared.invalidFormat"); + expect((error as CliUserError).exitCode).toBe(2); + expect((error as CliUserError).params).toEqual({ value: "yaml" }); + } + }); + }); + + describe("json-only mode", () => { + test("pins format to json without any flags", () => { + const output = createCommandOutput(createCollectingWriter([]), {}, "json-only"); + + expect(output.format).toBe("json"); + }); + + test("accepts --format json", () => { + const output = createCommandOutput( + createCollectingWriter([]), + { format: "json" }, + "json-only", + ); + + expect(output.format).toBe("json"); + }); + + test("still rejects an invalid --format value", () => { + expect(() => createCommandOutput( + createCollectingWriter([]), + { format: "yaml" }, + "json-only", + )).toThrow(CliUserError); + }); + }); + + describe("emit", () => { + test("writes the JSON payload and skips renderText in json mode", () => { + const chunks: string[] = []; + const output = createCommandOutput( + createCollectingWriter(chunks), + { format: "json" }, + "standard", + ); + let textRendered = false; + + output.emit({ ok: true }, () => { + textRendered = true; + }); + + expect(chunks.join("")).toBe(`{"ok":true}\n`); + expect(textRendered).toBe(false); + }); + + test("calls renderText and writes no JSON in text mode", () => { + const chunks: string[] = []; + const output = createCommandOutput( + createCollectingWriter(chunks), + {}, + "standard", + ); + let textRendered = false; + + output.emit({ ok: true }, () => { + textRendered = true; + }); + + expect(chunks).toEqual([]); + expect(textRendered).toBe(true); + }); + }); + + describe("emitJson", () => { + test("applies the schemaVersion envelope captured from the options", () => { + const chunks: string[] = []; + const output = createCommandOutput( + createCollectingWriter(chunks), + { format: "json", showSchemaVersion: true }, + "standard", + ); + + output.emitJson({ taskID: "task-1" }); + + expect(JSON.parse(chunks.join(""))).toEqual({ + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + taskID: "task-1", + }); + }); + + test("wraps array payloads under items when the envelope is on", () => { + const chunks: string[] = []; + const output = createCommandOutput( + createCollectingWriter(chunks), + { json: true, showSchemaVersion: true }, + "standard", + ); + + output.emitJson([1, 2]); + + expect(JSON.parse(chunks.join(""))).toEqual({ + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + items: [1, 2], + }); + }); + + test("writes bare JSON without --show-schema-version", () => { + const chunks: string[] = []; + const output = createCommandOutput( + createCollectingWriter(chunks), + { format: "json" }, + "standard", + ); + + output.emitJson([1, 2]); + + expect(chunks.join("")).toBe(`[1,2]\n`); + }); + + test("ignores a non-boolean showSchemaVersion value", () => { + const chunks: string[] = []; + const output = createCommandOutput( + createCollectingWriter(chunks), + { format: "json", showSchemaVersion: "yes" }, + "standard", + ); + + output.emitJson({ ok: true }); + + expect(chunks.join("")).toBe(`{"ok":true}\n`); + }); + }); +}); + +describe("writeJsonOutput", () => { + test("emits compact JSON with a trailing newline by default", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, { taskID: "task-1" }); + + expect(chunks.join("")).toBe(`{"taskID":"task-1"}\n`); + }); + + test("omits schemaVersion when showSchemaVersion is not set", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, { taskID: "task-1" }, { + showSchemaVersion: false, + }); + + expect(chunks.join("")).toBe(`{"taskID":"task-1"}\n`); + }); + + test("merges schemaVersion into object payloads", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, { taskID: "task-1" }, { + showSchemaVersion: true, + }); + + expect(JSON.parse(chunks.join(""))).toEqual({ + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + taskID: "task-1", + }); + }); + + test("places schemaVersion before object properties", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, { taskID: "task-1" }, { + showSchemaVersion: true, + }); + + expect(chunks.join("")).toBe( + `{"schemaVersion":"${JSON_OUTPUT_SCHEMA_VERSION}","taskID":"task-1"}\n`, + ); + }); + + test("wraps array payloads under items", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, [1, 2, 3], { showSchemaVersion: true }); + + expect(JSON.parse(chunks.join(""))).toEqual({ + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + items: [1, 2, 3], + }); + }); + + test("forces schemaVersion to override any existing field on objects", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, { schemaVersion: "2.0.0", value: 1 }, { + showSchemaVersion: true, + }); + + expect(JSON.parse(chunks.join(""))).toEqual({ + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + value: 1, + }); + }); + + test("wraps primitive payloads under value", () => { + const chunks: string[] = []; + const writer = createCollectingWriter(chunks); + + writeJsonOutput(writer, null, { showSchemaVersion: true }); + + expect(JSON.parse(chunks.join(""))).toEqual({ + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + value: null, + }); + }); +}); + +function createCollectingWriter(chunks: string[]): { write: (chunk: string) => void } { + return { + write: (chunk: string) => { + chunks.push(chunk); + }, + }; +} diff --git a/src/application/commands/command-output.ts b/src/application/commands/command-output.ts new file mode 100644 index 00000000..f99e9a92 --- /dev/null +++ b/src/application/commands/command-output.ts @@ -0,0 +1,151 @@ +import type { + CliCommandOutputMode, + CliOptionDefinition, + CommandOutput, + Writer, +} from "../contracts/cli.ts"; + +import { CliUserError } from "../contracts/cli.ts"; + +export const JSON_OUTPUT_SCHEMA_VERSION = "1.0.0"; + +export const outputFormatOptions = [ + { + name: "format", + longFlag: "--format", + valueName: "format", + descriptionKey: "options.format", + }, + { + name: "json", + longFlag: "--json", + descriptionKey: "options.json", + implies: { + format: "json", + }, + }, + { + name: "showSchemaVersion", + longFlag: "--show-schema-version", + descriptionKey: "options.showSchemaVersion", + }, +] as const satisfies readonly CliOptionDefinition[]; + +export interface OutputOptionValues { + format?: unknown; + json?: unknown; + showSchemaVersion?: unknown; +} + +const outputFormatValues = ["json"] as const; + +/** + * The one --json/--format precedence rule, shared by the telemetry event and + * createCommandOutput. Lenient: an invalid --format value resolves to "text"; + * strict rejection is createCommandOutput's job. + */ +export function resolveOutputFormat( + optionValues: OutputOptionValues, +): "json" | "text" { + return optionValues.format === "json" || optionValues.json === true + ? "json" + : "text"; +} + +/** + * Builds the per-invocation output handle handed to command handlers. + * + * Without a mode the handle is inert: no validation, text format, regardless of + * any format/json keys present in the option values (commands without a declared + * output mode never opt into the contract, and parent-command options may leak + * into child invocations via optsWithGlobals). With a mode, an invalid --format + * value throws the shared invalid-format error before any command work runs; + * "json-only" pins the format to "json" while still validating the flags. + */ +export function createCommandOutput( + writer: Writer, + optionValues: OutputOptionValues, + mode: CliCommandOutputMode | undefined, +): CommandOutput { + const format = mode === undefined + ? "text" + : resolveStrictOutputFormat(optionValues, mode); + const showSchemaVersion = optionValues.showSchemaVersion === true; + + const emitJson = (payload: unknown): void => { + writeJsonOutput(writer, payload, { showSchemaVersion }); + }; + + return { + format, + emit: (payload, renderText) => { + if (format === "json") { + emitJson(payload); + return; + } + + renderText(); + }, + emitJson, + }; +} + +function resolveStrictOutputFormat( + optionValues: OutputOptionValues, + mode: CliCommandOutputMode, +): "json" | "text" { + const format = optionValues.format; + + if ( + format !== undefined + && !outputFormatValues.includes(format as (typeof outputFormatValues)[number]) + ) { + throw new CliUserError("errors.shared.invalidFormat", 2, { + value: String(format), + }); + } + + return mode === "json-only" ? "json" : resolveOutputFormat(optionValues); +} + +export interface WriteJsonOutputOptions { + showSchemaVersion?: boolean | undefined; +} + +export function writeJsonOutput( + writer: Writer, + value: unknown, + options: WriteJsonOutputOptions = {}, +): void { + writer.write(`${JSON.stringify(applySchemaVersion(value, options))}\n`); +} + +function applySchemaVersion( + value: unknown, + options: WriteJsonOutputOptions, +): unknown { + if (options.showSchemaVersion !== true) { + return value; + } + + if (Array.isArray(value)) { + return { + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + items: value, + }; + } + + if (value !== null && typeof value === "object") { + const { schemaVersion: _ignored, ...rest } = value as Record; + + return { + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + ...rest, + }; + } + + return { + schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, + value, + }; +} diff --git a/src/application/commands/completion.test.ts b/src/application/commands/completion.test.ts index 252a314f..de89ac8e 100644 --- a/src/application/commands/completion.test.ts +++ b/src/application/commands/completion.test.ts @@ -1,6 +1,6 @@ import type { CliCatalog, - CliExecutionContext, + CliCommandContext, SupportedShell, } from "../contracts/cli.ts"; @@ -37,7 +37,7 @@ describe("completion command", () => { }, }, stdout: stdout.writer, - } as unknown as CliExecutionContext, + } as unknown as CliCommandContext, ); expect(renderedShell).toBe("fish"); diff --git a/src/application/commands/connector/apps.ts b/src/application/commands/connector/apps.ts index 9ddb3c39..01bfc048 100644 --- a/src/application/commands/connector/apps.ts +++ b/src/application/commands/connector/apps.ts @@ -6,7 +6,7 @@ import type { ConnectorAppView } from "./shared.ts"; import { z } from "zod"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { connectorSearchServiceColor } from "./search-provider.ts"; import { @@ -69,7 +69,7 @@ export const connectorAppsCommand: CliCommandDefinition = { personal: "options.connectorAppsPersonal", team: "options.connectorAppsTeam", }), - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ format: z.enum(connectorFormatValues).optional(), diff --git a/src/application/commands/connector/proxy.ts b/src/application/commands/connector/proxy.ts index 3eced799..39ae4481 100644 --- a/src/application/commands/connector/proxy.ts +++ b/src/application/commands/connector/proxy.ts @@ -5,7 +5,7 @@ import { Buffer } from "node:buffer"; import { z } from "zod"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { readJsonInputValue } from "../shared/json-input.ts"; import { @@ -116,7 +116,7 @@ export const connectorProxyCommand: CliCommandDefinition = personal: "options.connectorProxyPersonal", team: "options.connectorProxyTeam", }), - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ body: z.string().optional(), diff --git a/src/application/commands/connector/run.ts b/src/application/commands/connector/run.ts index aa170ef0..9925ad63 100644 --- a/src/application/commands/connector/run.ts +++ b/src/application/commands/connector/run.ts @@ -12,7 +12,7 @@ import { z } from "zod"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { readJsonInputValue } from "../shared/json-input.ts"; import { TerminalProgressRenderer } from "../shared/terminal-progress-renderer.ts"; @@ -125,7 +125,7 @@ export const connectorRunCommand: CliCommandDefinition = { personal: "options.connectorRunPersonal", team: "options.connectorRunTeam", }), - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ action: z.string().optional(), diff --git a/src/application/commands/connector/schema.ts b/src/application/commands/connector/schema.ts index 919cc98c..389a9de3 100644 --- a/src/application/commands/connector/schema.ts +++ b/src/application/commands/connector/schema.ts @@ -4,7 +4,7 @@ import type { ConnectorActionSchemaOutput } from "./schema-cache.ts"; import { z } from "zod"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; -import { writeJsonOutput } from "../json-output.ts"; +import { writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { createConnectorActionSchemaOutput, diff --git a/src/application/commands/connector/search.ts b/src/application/commands/connector/search.ts index d7051ebb..6dda43c0 100644 --- a/src/application/commands/connector/search.ts +++ b/src/application/commands/connector/search.ts @@ -5,7 +5,7 @@ import { bucketTelemetryCount, bucketTelemetryStringLength, } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { formatConnectorSearchResultsAsText, @@ -43,7 +43,7 @@ export const connectorSearchCommand: CliCommandDefinition personal: "options.searchPersonal", team: "options.searchTeam", }), - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ format: z.enum(connectorFormatValues).optional(), diff --git a/src/application/commands/file/cleanup.test.ts b/src/application/commands/file/cleanup.test.ts index 6a2dc295..3d4a09e8 100644 --- a/src/application/commands/file/cleanup.test.ts +++ b/src/application/commands/file/cleanup.test.ts @@ -1,4 +1,4 @@ -import type { CliExecutionContext } from "../../contracts/cli.ts"; +import type { CliCommandContext } from "../../contracts/cli.ts"; import { describe, expect, test } from "bun:test"; import { z } from "zod"; @@ -28,7 +28,7 @@ describe("file cleanup command", () => { }, stdout: stdout.writer, translator: createTranslator("en"), - } as unknown as CliExecutionContext, + } as unknown as CliCommandContext, ); expect(typeof deletedAt).toBe("number"); @@ -55,7 +55,7 @@ describe("file cleanup command", () => { }, stdout: stdout.writer, translator: createTranslator("en"), - } as unknown as CliExecutionContext, + } as unknown as CliCommandContext, ); expect(stdout.read()).toBe("{\"deletedCount\":2}\n"); @@ -82,7 +82,7 @@ describe("file cleanup command", () => { }, stdout: stdout.writer, translator: createTranslator("en"), - } as unknown as CliExecutionContext, + } as unknown as CliCommandContext, ); expect(JSON.parse(stdout.read())).toEqual({ @@ -111,7 +111,7 @@ describe("file cleanup command", () => { }, stdout: stdout.writer, translator: createTranslator("en"), - } as unknown as CliExecutionContext, + } as unknown as CliCommandContext, ); expect(stdout.read()).toBe( diff --git a/src/application/commands/file/cleanup.ts b/src/application/commands/file/cleanup.ts index 7015ec66..40ee4e77 100644 --- a/src/application/commands/file/cleanup.ts +++ b/src/application/commands/file/cleanup.ts @@ -1,7 +1,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError, parseFileFormat } from "./shared.ts"; interface FileCleanupInput { @@ -15,7 +15,7 @@ export const fileCleanupCommand: CliCommandDefinition = { name: "cleanup", summaryKey: "commands.file.cleanup.summary", descriptionKey: "commands.file.cleanup.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.string().optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/file/download.test.ts b/src/application/commands/file/download.test.ts index 6f19e872..4f9ca895 100644 --- a/src/application/commands/file/download.test.ts +++ b/src/application/commands/file/download.test.ts @@ -1,5 +1,6 @@ import type { CliCatalog, + CliCommandContext, CliExecutionContext, Fetcher, InteractiveInput, @@ -25,6 +26,7 @@ import { } from "../../../../__tests__/helpers.ts"; import { SidecarFileDownloadSessionStore } from "../../../adapters/store/sidecar-file-download-session-store.ts"; import { createTranslator } from "../../../i18n/translator.ts"; +import { createCommandOutput } from "../command-output.ts"; import { fileDownloadCommand } from "./download.ts"; import { createDownloadSessionRecordFixture, @@ -357,7 +359,7 @@ function createDownloadContext(options: { fileDownloadSessionStore: CliExecutionContext["fileDownloadSessionStore"]; settings: AppSettings; }): { - context: CliExecutionContext; + context: CliCommandContext; stderr: ReturnType; stdout: ReturnType; } { @@ -366,6 +368,7 @@ function createDownloadContext(options: { return { context: { + output: createCommandOutput(stdout.writer, {}, undefined), authStore: createAuthStore(emptyAuthFile), cacheStore: createCacheStore(), connectorStore: createInMemoryConnectorStore(), diff --git a/src/application/commands/file/list.ts b/src/application/commands/file/list.ts index 3670b8b8..b7ad3edc 100644 --- a/src/application/commands/file/list.ts +++ b/src/application/commands/file/list.ts @@ -1,7 +1,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError, parseFileFormat, @@ -23,7 +23,7 @@ export const fileListCommand: CliCommandDefinition = { summaryKey: "commands.file.list.summary", descriptionKey: "commands.file.list.description", options: [ - ...jsonOutputOptions, + ...outputFormatOptions, { name: "status", longFlag: "--status", diff --git a/src/application/commands/file/upload.ts b/src/application/commands/file/upload.ts index 04791b60..177909a2 100644 --- a/src/application/commands/file/upload.ts +++ b/src/application/commands/file/upload.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { completeMultipartFileUpload, createFormatInputError, @@ -46,7 +46,7 @@ export const fileUploadCommand: CliCommandDefinition = { required: true, }, ], - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.string().optional(), filePath: z.string(), diff --git a/src/application/commands/info.cli.test.ts b/src/application/commands/info.cli.test.ts index e896d94a..f2cc8ec4 100644 --- a/src/application/commands/info.cli.test.ts +++ b/src/application/commands/info.cli.test.ts @@ -9,7 +9,7 @@ import { } from "../../../__tests__/helpers.ts"; import { resolveStorePaths } from "../../adapters/store/store-path.ts"; import { APP_NAME } from "../config/app-config.ts"; -import { JSON_OUTPUT_SCHEMA_VERSION } from "./json-output.ts"; +import { JSON_OUTPUT_SCHEMA_VERSION } from "./command-output.ts"; import { availableBundledSkillAgentNames, resolveManagedSkillAgentHomeDirectory, diff --git a/src/application/commands/info.ts b/src/application/commands/info.ts index 056d9c65..d76e82c0 100644 --- a/src/application/commands/info.ts +++ b/src/application/commands/info.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { resolveStorePaths } from "../../adapters/store/store-path.ts"; import { APP_NAME } from "../config/app-config.ts"; import { createWriterColors } from "../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "./json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "./command-output.ts"; import { createFormatInputError } from "./shared/input-parsing.ts"; import { directoryExists } from "./skills/bundled-skill-observation.ts"; import { @@ -59,7 +59,7 @@ export const infoCommand: CliCommandDefinition = { name: "info", summaryKey: "commands.info.summary", descriptionKey: "commands.info.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(infoFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/json-output.test.ts b/src/application/commands/json-output.test.ts deleted file mode 100644 index 44857cd7..00000000 --- a/src/application/commands/json-output.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { JSON_OUTPUT_SCHEMA_VERSION, writeJsonOutput } from "./json-output.ts"; - -describe("writeJsonOutput", () => { - test("emits compact JSON with a trailing newline by default", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, { taskID: "task-1" }); - - expect(chunks.join("")).toBe(`{"taskID":"task-1"}\n`); - }); - - test("omits schemaVersion when showSchemaVersion is not set", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, { taskID: "task-1" }, { - showSchemaVersion: false, - }); - - expect(chunks.join("")).toBe(`{"taskID":"task-1"}\n`); - }); - - test("merges schemaVersion into object payloads", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, { taskID: "task-1" }, { - showSchemaVersion: true, - }); - - expect(JSON.parse(chunks.join(""))).toEqual({ - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - taskID: "task-1", - }); - }); - - test("places schemaVersion before object properties", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, { taskID: "task-1" }, { - showSchemaVersion: true, - }); - - expect(chunks.join("")).toBe( - `{"schemaVersion":"${JSON_OUTPUT_SCHEMA_VERSION}","taskID":"task-1"}\n`, - ); - }); - - test("wraps array payloads under items", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, [1, 2, 3], { showSchemaVersion: true }); - - expect(JSON.parse(chunks.join(""))).toEqual({ - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - items: [1, 2, 3], - }); - }); - - test("forces schemaVersion to override any existing field on objects", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, { schemaVersion: "2.0.0", value: 1 }, { - showSchemaVersion: true, - }); - - expect(JSON.parse(chunks.join(""))).toEqual({ - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - value: 1, - }); - }); - - test("wraps primitive payloads under value", () => { - const chunks: string[] = []; - const writer = createCollectingWriter(chunks); - - writeJsonOutput(writer, null, { showSchemaVersion: true }); - - expect(JSON.parse(chunks.join(""))).toEqual({ - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - value: null, - }); - }); -}); - -function createCollectingWriter(chunks: string[]): { write: (chunk: string) => void } { - return { - write: (chunk: string) => { - chunks.push(chunk); - }, - }; -} diff --git a/src/application/commands/json-output.ts b/src/application/commands/json-output.ts deleted file mode 100644 index 87e41ae1..00000000 --- a/src/application/commands/json-output.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { CliOptionDefinition, Writer } from "../contracts/cli.ts"; - -export const JSON_OUTPUT_SCHEMA_VERSION = "1.0.0"; - -export const jsonOutputOptions = [ - { - name: "format", - longFlag: "--format", - valueName: "format", - descriptionKey: "options.format", - }, - { - name: "json", - longFlag: "--json", - descriptionKey: "options.json", - implies: { - format: "json", - }, - }, - { - name: "showSchemaVersion", - longFlag: "--show-schema-version", - descriptionKey: "options.showSchemaVersion", - }, -] as const satisfies readonly CliOptionDefinition[]; - -export interface WriteJsonOutputOptions { - showSchemaVersion?: boolean | undefined; -} - -export function writeJsonOutput( - writer: Writer, - value: unknown, - options: WriteJsonOutputOptions = {}, -): void { - writer.write(`${JSON.stringify(applySchemaVersion(value, options))}\n`); -} - -function applySchemaVersion( - value: unknown, - options: WriteJsonOutputOptions, -): unknown { - if (options.showSchemaVersion !== true) { - return value; - } - - if (Array.isArray(value)) { - return { - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - items: value, - }; - } - - if (value !== null && typeof value === "object") { - const { schemaVersion: _ignored, ...rest } = value as Record; - - return { - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - ...rest, - }; - } - - return { - schemaVersion: JSON_OUTPUT_SCHEMA_VERSION, - value, - }; -} diff --git a/src/application/commands/llm/config.ts b/src/application/commands/llm/config.ts index 50a4c2b6..f71fc1b3 100644 --- a/src/application/commands/llm/config.ts +++ b/src/application/commands/llm/config.ts @@ -2,7 +2,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; const llmConfigFormatValues = ["json"] as const; @@ -25,7 +25,7 @@ export const llmConfigCommand: CliCommandDefinition = { excludeFromTelemetry: true, summaryKey: "commands.llm.config.summary", descriptionKey: "commands.llm.config.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(llmConfigFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/llm/json.ts b/src/application/commands/llm/json.ts index dea7477f..f972a0ff 100644 --- a/src/application/commands/llm/json.ts +++ b/src/application/commands/llm/json.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { readJsonInputValue } from "../shared/json-input.ts"; import { @@ -95,7 +95,7 @@ export const llmJsonCommand: CliCommandDefinition = { valueName: "model", descriptionKey: "options.model", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ format: z.enum(llmJsonFormatValues).optional(), diff --git a/src/application/commands/search.ts b/src/application/commands/search.ts index 360edd2d..f1590459 100644 --- a/src/application/commands/search.ts +++ b/src/application/commands/search.ts @@ -6,6 +6,7 @@ import { bucketTelemetryCount, bucketTelemetryStringLength, } from "../telemetry/buckets.ts"; +import { outputFormatOptions, writeJsonOutput } from "./command-output.ts"; import { formatConnectorSearchResultsAsText, loadConnectorSearchResults, @@ -15,7 +16,6 @@ import { teamIdentityInputShape, teamIdentityOptions, } from "./connector/session.ts"; -import { jsonOutputOptions, writeJsonOutput } from "./json-output.ts"; import { createFormatInputError } from "./shared/input-parsing.ts"; const searchFormatValues = ["json"] as const; @@ -45,7 +45,7 @@ export const searchCommand: CliCommandDefinition = { personal: "options.searchPersonal", team: "options.searchTeam", }), - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ format: z.enum(searchFormatValues).optional(), diff --git a/src/application/commands/skills/check-update.ts b/src/application/commands/skills/check-update.ts index 12833999..3c329334 100644 --- a/src/application/commands/skills/check-update.ts +++ b/src/application/commands/skills/check-update.ts @@ -9,7 +9,7 @@ import { requireIdentity } from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; import { compareSemver } from "../../semver.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { writeLine } from "../shared/output.ts"; import { @@ -116,7 +116,7 @@ export const skillsCheckUpdateCommand: CliCommandDefinition = { valueName: "agent", descriptionKey: "options.skills.install.agentFormat", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ force: z.boolean().optional(), diff --git a/src/application/commands/skills/list.ts b/src/application/commands/skills/list.ts index a141f573..e7de25e9 100644 --- a/src/application/commands/skills/list.ts +++ b/src/application/commands/skills/list.ts @@ -13,7 +13,7 @@ import type { SkillListSource } from "./managed-skill-listings.ts"; import { z } from "zod"; import { CliUserError } from "../../contracts/cli.ts"; import { createWriterColors } from "../../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { collectSkillsInfoInventory } from "./info-inventory.ts"; import { parseManagedSkillAgentOption, @@ -51,7 +51,7 @@ export const skillsListCommand: CliCommandDefinition = { valueName: "source", descriptionKey: "options.skillListSource", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ agent: z.string().optional(), diff --git a/src/application/commands/skills/recommend/plan.ts b/src/application/commands/skills/recommend/plan.ts index a6d9a187..0699ac90 100644 --- a/src/application/commands/skills/recommend/plan.ts +++ b/src/application/commands/skills/recommend/plan.ts @@ -12,7 +12,7 @@ import { } from "../../../schemas/settings.ts"; import { compareSemver } from "../../../semver.ts"; import { bucketTelemetryCount } from "../../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../../command-output.ts"; import { createFormatInputError } from "../../shared/input-parsing.ts"; import { writeLine } from "../../shared/output.ts"; import { @@ -75,7 +75,7 @@ export const skillsRecommendPlanCommand: CliCommandDefinition = { valueName: "agents...", descriptionKey: "options.skills.repair.agent", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ agent: z.array(z.string()).optional(), diff --git a/src/application/commands/skills/search.test.ts b/src/application/commands/skills/search.test.ts index 4b5e6db2..c76f43e5 100644 --- a/src/application/commands/skills/search.test.ts +++ b/src/application/commands/skills/search.test.ts @@ -1,6 +1,6 @@ import type { CliCatalog, - CliExecutionContext, + CliCommandContext, CliTelemetryPropertyValue, Fetcher, InteractiveInput, @@ -20,6 +20,7 @@ import { createTextBuffer, toRequest, } from "../../../../__tests__/helpers.ts"; +import { createCommandOutput } from "../command-output.ts"; import { skillsSearchCommand } from "./search.ts"; const searchHandler = skillsSearchCommand.handler!; @@ -184,13 +185,14 @@ describe("skillsSearchCommand", () => { function createSearchContext(options: { fetcher: Fetcher; telemetryProperties?: Record; -}): CliExecutionContext & { +}): CliCommandContext & { stdoutBuffer: ReturnType; } { const stdoutBuffer = createTextBuffer(); const stderr = createTextBuffer(); return { + output: createCommandOutput(stdoutBuffer.writer, {}, undefined), authStore: createAuthStore(activeAuthFile), cacheStore: { close() {}, diff --git a/src/application/commands/skills/search.ts b/src/application/commands/skills/search.ts index 2c1cf833..b339eb5e 100644 --- a/src/application/commands/skills/search.ts +++ b/src/application/commands/skills/search.ts @@ -9,7 +9,7 @@ import { bucketTelemetryStringLength, } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { parseCommaSeparatedKeywords } from "../shared/keywords.ts"; import { requestOo } from "../shared/oo-request.ts"; @@ -63,7 +63,7 @@ export const skillsSearchCommand: CliCommandDefinition = { }, ], options: [ - ...jsonOutputOptions, + ...outputFormatOptions, { name: "keywords", longFlag: "--keywords", diff --git a/src/application/commands/skills/sync.ts b/src/application/commands/skills/sync.ts index b54a8c8e..1073c451 100644 --- a/src/application/commands/skills/sync.ts +++ b/src/application/commands/skills/sync.ts @@ -13,7 +13,7 @@ import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { parseCommaSeparatedValues } from "../shared/list-parsing.ts"; import { requestOo } from "../shared/oo-request.ts"; @@ -98,7 +98,7 @@ export const skillsSyncCommand: CliCommandDefinition = { valueName: "patterns...", descriptionKey: "options.skillSyncIgnore", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ ignore: z.array(z.string()).optional(), @@ -153,7 +153,7 @@ export const skillsSyncCommand: CliCommandDefinition = { valueName: "source", descriptionKey: "options.skillSyncSource", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ source: z.string().optional(), diff --git a/src/application/commands/skills/uninstall.ts b/src/application/commands/skills/uninstall.ts index 39f08253..b69f7c3f 100644 --- a/src/application/commands/skills/uninstall.ts +++ b/src/application/commands/skills/uninstall.ts @@ -17,7 +17,7 @@ import type { import { z } from "zod"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { removePath } from "./bundled-skill-filesystem.ts"; import { availableBundledSkillNames } from "./embedded-assets.ts"; @@ -96,7 +96,7 @@ export const skillsUninstallCommand: CliCommandDefinition valueName: "agent", descriptionKey: "options.agent", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ agent: z.string().optional(), diff --git a/src/application/commands/skills/update.ts b/src/application/commands/skills/update.ts index 4e383fdd..f4c8b1d3 100644 --- a/src/application/commands/skills/update.ts +++ b/src/application/commands/skills/update.ts @@ -16,7 +16,7 @@ import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { writeLine } from "../shared/output.ts"; import { @@ -133,7 +133,7 @@ export const skillsUpdateCommand: CliCommandDefinition = { valueName: "skills...", descriptionKey: "options.skills.skill", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ packageNames: z.array(z.string()).optional(), diff --git a/src/application/commands/team/current.ts b/src/application/commands/team/current.ts index 6e28f7f5..531b4f09 100644 --- a/src/application/commands/team/current.ts +++ b/src/application/commands/team/current.ts @@ -7,7 +7,7 @@ import type { import { z } from "zod"; import { resolveIdentity } from "../../auth/identity.ts"; import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { writeLine } from "../shared/output.ts"; import { @@ -52,7 +52,7 @@ export const teamCurrentCommand: CliCommandDefinition = { name: "current", summaryKey: "commands.team.current.summary", descriptionKey: "commands.team.current.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(teamFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/team/list.ts b/src/application/commands/team/list.ts index 41b18c43..7ff61aaa 100644 --- a/src/application/commands/team/list.ts +++ b/src/application/commands/team/list.ts @@ -9,7 +9,7 @@ import { requireIdentity } from "../../auth/identity.ts"; import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; import { resolveTeamIdentity } from "./identity.ts"; import { listMemberTeams, teamFormatValues } from "./shared.ts"; @@ -30,7 +30,7 @@ export const teamListCommand: CliCommandDefinition = { name: "list", summaryKey: "commands.team.list.summary", descriptionKey: "commands.team.list.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(teamFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/variables/create.ts b/src/application/commands/variables/create.ts index 88977caf..b4d9087e 100644 --- a/src/application/commands/variables/create.ts +++ b/src/application/commands/variables/create.ts @@ -1,7 +1,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { writeLine } from "../shared/output.ts"; import { mapVariablesInputError, @@ -50,7 +50,7 @@ export const variablesCreateCommand: CliCommandDefinition longFlag: "--stdin", descriptionKey: "options.variablesStdin", }, - ...jsonOutputOptions, + ...outputFormatOptions, ], inputSchema: z.object({ name: variableNameSchema, diff --git a/src/application/commands/variables/delete.ts b/src/application/commands/variables/delete.ts index bd748556..90130f12 100644 --- a/src/application/commands/variables/delete.ts +++ b/src/application/commands/variables/delete.ts @@ -1,7 +1,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { writeLine } from "../shared/output.ts"; import { deleteVariable, mapVariablesInputError, variableFormatValues, variableNameSchema } from "./shared.ts"; @@ -23,7 +23,7 @@ export const variablesDeleteCommand: CliCommandDefinition required: true, }, ], - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ name: variableNameSchema, format: z.enum(variableFormatValues).optional(), diff --git a/src/application/commands/variables/get.ts b/src/application/commands/variables/get.ts index a08c34d3..f807a1c7 100644 --- a/src/application/commands/variables/get.ts +++ b/src/application/commands/variables/get.ts @@ -1,7 +1,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { writeLine } from "../shared/output.ts"; import { getVariable, mapVariablesInputError, variableFormatValues, variableNameSchema } from "./shared.ts"; @@ -23,7 +23,7 @@ export const variablesGetCommand: CliCommandDefinition = { required: true, }, ], - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ name: variableNameSchema, format: z.enum(variableFormatValues).optional(), diff --git a/src/application/commands/variables/list.ts b/src/application/commands/variables/list.ts index 746665de..7964d645 100644 --- a/src/application/commands/variables/list.ts +++ b/src/application/commands/variables/list.ts @@ -1,7 +1,7 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; -import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "../command-output.ts"; import { writeLine } from "../shared/output.ts"; import { listVariables, mapVariablesInputError, variableFormatValues } from "./shared.ts"; import { formatVariableListLine } from "./text.ts"; @@ -15,7 +15,7 @@ export const variablesListCommand: CliCommandDefinition = { name: "list", summaryKey: "commands.variables.list.summary", descriptionKey: "commands.variables.list.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(variableFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/commands/version.ts b/src/application/commands/version.ts index f0a3d901..565f77b5 100644 --- a/src/application/commands/version.ts +++ b/src/application/commands/version.ts @@ -6,7 +6,7 @@ import { resolveCliBuildInfo, shortenCommitHash, } from "../config/build-info.ts"; -import { jsonOutputOptions, writeJsonOutput } from "./json-output.ts"; +import { outputFormatOptions, writeJsonOutput } from "./command-output.ts"; import { createFormatInputError } from "./shared/input-parsing.ts"; const versionFormatValues = ["json"] as const; @@ -20,7 +20,7 @@ export const versionCommand: CliCommandDefinition = { name: "version", summaryKey: "commands.version.summary", descriptionKey: "commands.version.description", - options: [...jsonOutputOptions], + options: [...outputFormatOptions], inputSchema: z.object({ format: z.enum(versionFormatValues).optional(), showSchemaVersion: z.boolean().optional(), diff --git a/src/application/contracts/cli.ts b/src/application/contracts/cli.ts index ff72c7ac..462f6e2e 100644 --- a/src/application/contracts/cli.ts +++ b/src/application/contracts/cli.ts @@ -61,10 +61,12 @@ export interface CliArgumentDefinition { export type CliCommandHandler = { bivarianceHack: ( input: TInput, - context: CliExecutionContext, + context: CliCommandContext, ) => Promise | void; }["bivarianceHack"]; +export type CliCommandOutputMode = "standard" | "json-only"; + export interface CliCommandDefinition { name: string; aliases?: readonly string[]; @@ -74,6 +76,12 @@ export interface CliCommandDefinition { descriptionKey?: string; arguments?: readonly CliArgumentDefinition[]; options?: readonly CliOptionDefinition[]; + /** + * Declares the command's relationship to the JSON/text output contract. + * When set, the adapter attaches the shared output options and builds a + * strict output handle; undeclared commands get an inert text handle. + */ + output?: CliCommandOutputMode; missingArgumentBehavior?: "error" | "showHelp"; inputSchema?: ZodType; mapInputError?: ( @@ -140,6 +148,15 @@ export interface CompletionRenderer { render: (shell: SupportedShell, catalog: CliCatalog) => string; } +export interface CommandOutput { + /** Resolved once per invocation; "json" iff --format json / --json (or json-only mode). */ + format: "json" | "text"; + /** Standard shape: JSON mode writes the payload (with envelope), text mode calls renderText. */ + emit: (payload: unknown, renderText: () => void) => void; + /** For format-gated paths (report writers, json-only commands): write JSON with envelope. */ + emitJson: (payload: unknown) => void; +} + export interface CliExecutionContext { authStore: AuthStore; cacheStore: CacheStore; @@ -172,6 +189,11 @@ export interface CliExecutionContext { versionText?: string; } +/** The context command handlers receive: the execution context plus the output handle. */ +export interface CliCommandContext extends CliExecutionContext { + output: CommandOutput; +} + export type CliMessageParams = Record; export class CliUserError extends Error {