From 7acbf807b9165f3b5b8c0e1b3d15482c290b09f9 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 3 Jul 2026 15:39:15 +0200 Subject: [PATCH 1/5] chore(deps): bump @types/node from ^25.9.2 to ^26.0.0 --- package.json | 2 +- pnpm-lock.yaml | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 530c01a..3cc894c 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@toon-format/toon": "^2.3.0", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "chalk": "^5.6.2", "commander": "^15.0.0", "fuzzysort": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7be8c09..83fa475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^2.3.0 version: 2.3.0 '@types/node': - specifier: ^25.9.2 - version: 25.9.2 + specifier: ^26.0.0 + version: 26.0.1 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -90,7 +90,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@types/node@26.0.1)(@vitest/coverage-v8@4.1.8)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -946,8 +946,8 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@25.9.2': - resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} @@ -2112,8 +2112,8 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} @@ -2780,13 +2780,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.9.2 + '@types/node': 26.0.1 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.9.2 + '@types/node': 26.0.1 '@types/ssh2': 1.15.5 '@types/estree@1.0.9': {} @@ -2795,9 +2795,9 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@25.9.2': + '@types/node@26.0.1': dependencies: - undici-types: 7.24.6 + undici-types: 8.3.0 '@types/react@19.2.17': dependencies: @@ -2805,11 +2805,11 @@ snapshots: '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.9.2 + '@types/node': 26.0.1 '@types/ssh2@0.5.52': dependencies: - '@types/node': 25.9.2 + '@types/node': 26.0.1 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -2828,7 +2828,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@types/node@26.0.1)(@vitest/coverage-v8@4.1.8)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -2839,13 +2839,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -3730,7 +3730,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.2 + '@types/node': 26.0.1 long: 5.3.2 proxy-addr@2.0.7: @@ -4119,7 +4119,7 @@ snapshots: undici-types@5.26.5: {} - undici-types@7.24.6: {} + undici-types@8.3.0: {} undici@7.28.0: {} @@ -4129,7 +4129,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -4138,16 +4138,16 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.2 + '@types/node': 26.0.1 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@types/node@26.0.1)(@vitest/coverage-v8@4.1.8)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -4164,10 +4164,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@25.9.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.2 + '@types/node': 26.0.1 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) transitivePeerDependencies: - msw From a7ee6edaf416303e68209232660c2644492871e7 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 3 Jul 2026 15:48:20 +0200 Subject: [PATCH 2/5] refactor(cli): replace chalk with node:util styleText --- package.json | 1 - pnpm-lock.yaml | 3 -- src/cli/errors.ts | 18 ++++++++--- src/config/helpers/cli.ts | 26 +++++++++------ test/cli/errors.test.ts | 52 +++++++++++++++++++++++++++++- test/config/helpers/cli.test.ts | 57 +++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 3cc894c..a96613d 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@toon-format/toon": "^2.3.0", "@types/node": "^26.0.0", - "chalk": "^5.6.2", "commander": "^15.0.0", "fuzzysort": "^3.1.0", "ink": "^7.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83fa475..ab929a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,9 +27,6 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.1 - chalk: - specifier: ^5.6.2 - version: 5.6.2 commander: specifier: ^15.0.0 version: 15.0.0 diff --git a/src/cli/errors.ts b/src/cli/errors.ts index ea749ae..9731f8b 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -1,4 +1,4 @@ -import chalk from "chalk"; +import { styleText } from "node:util"; import { ConfigError } from "#src/config/index.js"; @@ -9,6 +9,14 @@ const defaultDeps: RenderErrorDeps = { exit: (code) => process.exit(code), }; +/** Applies `format` only when stderr is a TTY and `NO_COLOR` is unset. */ +function paint(format: Parameters[0], text: string): string { + if (process.env.NO_COLOR !== undefined || !process.stderr.isTTY) { + return text; + } + return styleText(format, text, { validateStream: false }); +} + export interface RenderErrorDeps { write: (text: string) => void; exit: (code: number) => never; @@ -16,13 +24,13 @@ export interface RenderErrorDeps { /** * Render an error at the CLI boundary and exit(1). A `ConfigError` is styled - * with a red `✖` prefix via chalk (auto-disabled on non-TTY / `NO_COLOR`); - * anything else prints its plain message. ANSI styling lives here, never in - * `ConfigError.message`. + * with a red `✖` prefix via `styleText` (auto-disabled on non-TTY / + * `NO_COLOR`); anything else prints its plain message. ANSI styling lives + * here, never in `ConfigError.message`. */ export function renderCliError(error: unknown, deps: RenderErrorDeps = defaultDeps): never { if (error instanceof ConfigError) { - deps.write(`\n ${chalk.bold.red("✖")} ${chalk.red(error.message)}\n`); + deps.write(`\n ${paint(["bold", "red"], "✖")} ${paint("red", error.message)}\n`); } else { const message = error instanceof Error ? error.message : String(error); deps.write(`${message}\n`); diff --git a/src/config/helpers/cli.ts b/src/config/helpers/cli.ts index a30f01a..f228d88 100644 --- a/src/config/helpers/cli.ts +++ b/src/config/helpers/cli.ts @@ -1,23 +1,31 @@ -import chalk from "chalk"; +import { styleText } from "node:util"; import { ConfigError } from "#src/config/errors.js"; import type { CliHelpers, ConfigNotice, NoticeLevel, NoticeSink } from "#src/config/types.js"; -const NOTICE_STYLES: Record string }> = { - warn: { glyph: "⚠", color: chalk.yellow }, - info: { glyph: "ℹ", color: chalk.blue }, - success: { glyph: "✔", color: chalk.green }, +const NOTICE_STYLES: Record = { + warn: { glyph: "⚠", color: "yellow" }, + info: { glyph: "ℹ", color: "blue" }, + success: { glyph: "✔", color: "green" }, }; +/** Applies `format` only when stderr is a TTY and `NO_COLOR` is unset. */ +function paint(format: Parameters[0], text: string): string { + if (process.env.NO_COLOR !== undefined || !process.stderr.isTTY) { + return text; + } + return styleText(format, text, { validateStream: false }); +} + /** - * Default `NoticeSink`: writes chalk-styled lines to stderr (one bold glyph + - * colored message per level). chalk auto-disables color on non-TTY / `NO_COLOR`. - * Mirrors `renderCliError`'s `✖` style from P01-T06. + * Default `NoticeSink`: writes `styleText`-styled lines to stderr (one bold + * glyph + colored message per level). Color auto-disables on non-TTY / + * `NO_COLOR`. Mirrors `renderCliError`'s `✖` style from P01-T06. */ export function createStderrSink(): NoticeSink { return (notice: ConfigNotice): void => { const { glyph, color } = NOTICE_STYLES[notice.level]; - process.stderr.write(`\n ${chalk.bold(color(glyph))} ${color(notice.message)}\n`); + process.stderr.write(`\n ${paint(["bold", color], glyph)} ${paint(color, notice.message)}\n`); }; } diff --git a/test/cli/errors.test.ts b/test/cli/errors.test.ts index 3c64297..2b025ef 100644 --- a/test/cli/errors.test.ts +++ b/test/cli/errors.test.ts @@ -1,8 +1,23 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { renderCliError } from "../../src/cli/errors.js"; import { ConfigError } from "../../src/config/errors.js"; +/** Runs `fn` with `process.stderr.isTTY` forced to `isTTY`, then restores. */ +function withStderrTTY(isTTY: boolean, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { value: isTTY, configurable: true }); + try { + return fn(); + } finally { + if (descriptor) { + Object.defineProperty(process.stderr, "isTTY", descriptor); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } + } +} + function makeDeps() { const writes: string[] = []; const exits: number[] = []; @@ -46,6 +61,41 @@ describe("renderCliError", () => { expect(exits).toEqual([1]); }); + describe("color handling", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("emits bold-red ✖ ANSI when stderr is a TTY and NO_COLOR is unset", () => { + vi.stubEnv("NO_COLOR", undefined); + const { writes, deps } = makeDeps(); + withStderrTTY(true, () => + renderCliError(new ConfigError("bad config", { kind: "validation" }), deps), + ); + expect(writes[0]).toBe("\n \x1b[1m\x1b[31m✖\x1b[39m\x1b[22m \x1b[31mbad config\x1b[39m\n"); + }); + + it("emits no ANSI when NO_COLOR is set, even on a TTY stderr", () => { + vi.stubEnv("NO_COLOR", "1"); + const { writes, deps } = makeDeps(); + withStderrTTY(true, () => + renderCliError(new ConfigError("bad config", { kind: "validation" }), deps), + ); + expect(writes[0]).toBe("\n ✖ bad config\n"); + expect(writes[0]).not.toContain("\x1b["); + }); + + it("emits no ANSI when stderr is not a TTY", () => { + vi.stubEnv("NO_COLOR", undefined); + const { writes, deps } = makeDeps(); + withStderrTTY(false, () => + renderCliError(new ConfigError("bad config", { kind: "validation" }), deps), + ); + expect(writes[0]).toBe("\n ✖ bad config\n"); + expect(writes[0]).not.toContain("\x1b["); + }); + }); + it("uses default deps (process.stderr + process.exit) when none injected", () => { const writeSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); diff --git a/test/config/helpers/cli.test.ts b/test/config/helpers/cli.test.ts index d0538ce..b906c32 100644 --- a/test/config/helpers/cli.test.ts +++ b/test/config/helpers/cli.test.ts @@ -4,6 +4,21 @@ import { ConfigError } from "../../../src/config/errors.js"; import { createCliHelpers, createStderrSink } from "../../../src/config/helpers/cli.js"; import type { ConfigNotice } from "../../../src/config/types.js"; +/** Runs `fn` with `process.stderr.isTTY` forced to `isTTY`, then restores. */ +function withStderrTTY(isTTY: boolean, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { value: isTTY, configurable: true }); + try { + return fn(); + } finally { + if (descriptor) { + Object.defineProperty(process.stderr, "isTTY", descriptor); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } + } +} + describe("createCliHelpers", () => { it("fatal throws ConfigError(kind:fatal) with the field passthrough", () => { const notices: ConfigNotice[] = []; @@ -47,6 +62,48 @@ describe("createCliHelpers", () => { describe("createStderrSink", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + function captureStderr(): string[] { + const writes: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); + return writes; + } + + it("emits bold glyph + colored message per level on a TTY without NO_COLOR", () => { + vi.stubEnv("NO_COLOR", undefined); + const writes = captureStderr(); + const sink = createStderrSink(); + withStderrTTY(true, () => { + sink({ level: "warn", message: "careful" }); + sink({ level: "info", message: "fyi" }); + sink({ level: "success", message: "done" }); + }); + expect(writes).toEqual([ + "\n \x1b[1m\x1b[33m⚠\x1b[39m\x1b[22m \x1b[33mcareful\x1b[39m\n", + "\n \x1b[1m\x1b[34mℹ\x1b[39m\x1b[22m \x1b[34mfyi\x1b[39m\n", + "\n \x1b[1m\x1b[32m✔\x1b[39m\x1b[22m \x1b[32mdone\x1b[39m\n", + ]); + }); + + it("emits no ANSI when NO_COLOR is set, even on a TTY stderr", () => { + vi.stubEnv("NO_COLOR", "1"); + const writes = captureStderr(); + const sink = createStderrSink(); + withStderrTTY(true, () => sink({ level: "warn", message: "careful" })); + expect(writes).toEqual(["\n ⚠ careful\n"]); + }); + + it("emits no ANSI when stderr is not a TTY", () => { + vi.stubEnv("NO_COLOR", undefined); + const writes = captureStderr(); + const sink = createStderrSink(); + withStderrTTY(false, () => sink({ level: "info", message: "fyi" })); + expect(writes).toEqual(["\n ℹ fyi\n"]); }); it("writes a styled line per level to stderr", () => { From 061786ad966510b1788929cbc30b84faa8432a92 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 3 Jul 2026 16:13:33 +0200 Subject: [PATCH 3/5] refactor(cli): replace commander with cleye --- package.json | 2 +- pnpm-lock.yaml | 30 +- src/cli.tsx | 741 ++++++++++++++++++++++++++++++++++++----------- test/cli.test.ts | 210 +++++++++++++- 4 files changed, 795 insertions(+), 188 deletions(-) diff --git a/package.json b/package.json index a96613d..d4f9679 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@toon-format/toon": "^2.3.0", "@types/node": "^26.0.0", - "commander": "^15.0.0", + "cleye": "^2.6.0", "fuzzysort": "^3.1.0", "ink": "^7.0.5", "jiti": "^2.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab929a7..35162ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,9 +27,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.1 - commander: - specifier: ^15.0.0 - version: 15.0.0 + cleye: + specifier: ^2.6.0 + version: 2.6.0 fuzzysort: specifier: ^3.1.0 version: 3.1.0 @@ -1184,6 +1184,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cleye@2.6.0: + resolution: {integrity: sha512-u0SQCsega/ox+2GSuUlG6wvA9c2FtH8sPmv9G9Q3JRTs7FK6+LtaziRAQgx7lrJ1J7bOd3palhwgZKMg8R6JbQ==} + cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -1211,10 +1214,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@15.0.0: - resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} - engines: {node: '>=22.12.0'} - compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -2048,6 +2047,9 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terminal-columns@2.0.0: + resolution: {integrity: sha512-6IByuUjyNZJXUtwDNm+OIe62zgwwaRbH+WMNTcx05O2G5V9WhvluAAHJY8OvUdwmzMPpqAD/7EUpGdI6ae1aiQ==} + terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -2097,6 +2099,9 @@ packages: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} + type-flag@4.5.0: + resolution: {integrity: sha512-1aLzxcL6u1O9XHieAJBBX9U4QzwzDTWN0ER9M7QQSvS24NBmGM+N8FcghlgHAzOvDlEEpOx4hEml9CVcDnflcw==} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -3055,6 +3060,11 @@ snapshots: chownr@1.1.4: {} + cleye@2.6.0: + dependencies: + terminal-columns: 2.0.0 + type-flag: 4.5.0 + cli-boxes@4.0.1: {} cli-cursor@4.0.0: @@ -3082,8 +3092,6 @@ snapshots: color-name@1.1.4: {} - commander@15.0.0: {} - compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -4046,6 +4054,8 @@ snapshots: - bare-abort-controller - react-native-b4a + terminal-columns@2.0.0: {} + terminal-size@4.0.1: {} testcontainers@12.0.1: @@ -4106,6 +4116,8 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-flag@4.5.0: {} + type-is@2.1.0: dependencies: content-type: 2.0.0 diff --git a/src/cli.tsx b/src/cli.tsx index afe193c..6335d91 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { program } from "commander"; +import { cli, command } from "cleye"; +import type { Command } from "cleye"; import { renderCliError } from "./cli/errors.js"; import type { SessionInfo, SessionIpc } from "./cli/helpers.js"; @@ -37,16 +38,74 @@ declare const __VERSION__: string; declare const __BUILD_TIME__: string; declare const __BUILD_BRANCH__: string; -program - .name("zaps") - .version( - `${typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev"} (${resolveRuntime()}) built ${typeof __BUILD_TIME__ !== "undefined" ? __BUILD_TIME__ : "from source"}${typeof __BUILD_BRANCH__ !== "undefined" ? ` [${__BUILD_BRANCH__}]` : ""}`, - ) - .description("Terminal session manager") - .option("-s, --session ", "Target session by id/name prefix"); +const VERSION = `${typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev"} (${resolveRuntime()}) built ${typeof __BUILD_TIME__ !== "undefined" ? __BUILD_TIME__ : "from source"}${typeof __BUILD_BRANCH__ !== "undefined" ? ` [${__BUILD_BRANCH__}]` : ""}`; + +// --- Global -s/--session (commander parity) --- + +/** + * Commander exposed a root-level `-s/--session` readable from every subcommand + * via `program.opts()`, accepted both before and after the command name. cleye + * has no inherited flags, so parity is kept with a thin adapter: leading + * occurrences are hoisted off argv before dispatch, and every command declares + * the same flag and folds its parsed value back in via `adoptGlobalSession`. + */ +let sessionOption: string | undefined = undefined; function globalSession(): string | undefined { - return program.opts().session as string | undefined; + return sessionOption; +} + +function adoptGlobalSession(session: string | undefined): void { + if (session !== undefined) { + sessionOption = session; + } +} + +/** Hoist leading `-s ` / `--session ` / `--session=` off argv. */ +function consumeLeadingSessionFlag(argv: string[]): string[] { + const rest = [...argv]; + let consuming = true; + while (consuming && rest.length > 0) { + const [arg] = rest; + if (arg === "-s" || arg === "--session") { + if (rest.length < 2) { + process.stderr.write("error: option '-s, --session ' argument missing\n"); + process.exit(1); + } + [, sessionOption] = rest; + rest.splice(0, 2); + } else if (arg.startsWith("--session=")) { + sessionOption = arg.slice("--session=".length); + rest.shift(); + } else { + consuming = false; + } + } + return rest; +} + +const sessionFlag = { + session: { + type: String, + alias: "s", + placeholder: "", + description: "Target session by id/name prefix", + }, +}; + +const formatFlags = { + json: { type: Boolean, description: "Output as JSON" }, + toon: { type: Boolean, description: "Output as TOON" }, +}; + +/** Commander errors on excess command-arguments by default; keep that. */ +function rejectExcessArgs(name: string, args: readonly string[], expected: number): void { + if (args.length > expected) { + process.stderr.write( + `error: too many arguments for '${name}'. Expected ${expected} argument${expected === 1 ? "" : "s"} but got ${args.length}.\n`, + ); + process.exit(1); + } } // --- TUI --- @@ -268,7 +327,7 @@ async function upFlow(detach?: boolean): Promise { const originPane = await currentPaneId(); const tmuxSession = await currentSession(); - const command = resolveCommand(); + const zapsCommand = resolveCommand(); const sock = await ensureDaemon(resolveCommandArgv()); const res = await ipcRequest(sock, "session.create", { @@ -315,7 +374,7 @@ async function upFlow(detach?: boolean): Promise { } else { await sendKeys( tuiPaneId, - `${command} ui --session ${session.id} --socket ${sock} --start; exit`, + `${zapsCommand} ui --session ${session.id} --socket ${sock} --start; exit`, ); await selectPane(focusPane); } @@ -323,11 +382,19 @@ async function upFlow(detach?: boolean): Promise { // --- Smart default: attach if running, else up --- -program - .command("up") - .description("Create session, start services, attach TUI") - .option("-d, --detach", "Start without attaching TUI") - .action(async (opts: { detach?: boolean }) => { +const upCommand = command( + { + name: "up", + flags: { + ...sessionFlag, + detach: { type: Boolean, alias: "d", description: "Start without attaching TUI" }, + }, + help: { description: "Create session, start services, attach TUI" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("up", parsed._, 0); + const opts = { detach: parsed.flags.detach }; const sessionOpt = globalSession(); if (sessionOpt && isDaemonRunning()) { const sock = socketPath(); @@ -378,14 +445,20 @@ program } await upFlow(opts.detach); - }); + }, +); // --- Core Lifecycle --- -program - .command("down") - .description("Stop all services and destroy session") - .action(async () => { +const downCommand = command( + { + name: "down", + flags: { ...sessionFlag }, + help: { description: "Stop all services and destroy session" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("down", parsed._, 0); const code = await runDown({ daemonRunning: isDaemonRunning, socket: socketPath, @@ -403,17 +476,25 @@ program if (code !== 0) { process.exit(code); } - }); + }, +); // --- Service Operations (flat, variadic) --- -for (const action of ["start", "stop", "restart"] as const) { - program - .command(`${action} [services...]`) - .description(`${action.charAt(0).toUpperCase()}${action.slice(1)} service(s). All if omitted`) - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (services: string[], opts: { json?: boolean; toon?: boolean }) => { +function serviceActionCommand(action: "start" | "stop" | "restart") { + return command( + { + name: action, + parameters: ["[services...]"], + flags: { ...sessionFlag, ...formatFlags }, + help: { + description: `${action.charAt(0).toUpperCase()}${action.slice(1)} service(s). All if omitted`, + }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + const { services } = parsed._; + const opts = parsed.flags; try { await withDaemon(async (ipc) => { const params = services.length > 0 ? { names: services } : undefined; @@ -438,17 +519,26 @@ for (const action of ["start", "stop", "restart"] as const) { } throw error; } - }); + }, + ); } +const startCommand = serviceActionCommand("start"); +const stopCommand = serviceActionCommand("stop"); +const restartCommand = serviceActionCommand("restart"); + // --- Query --- -program - .command("ps") - .description("List services and their status") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (opts: { json?: boolean; toon?: boolean }) => { +const psCommand = command( + { + name: "ps", + flags: { ...sessionFlag, ...formatFlags }, + help: { description: "List services and their status" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("ps", parsed._, 0); + const opts = parsed.flags; try { await withDaemon(async (ipc) => { const res = await ipc.request("services.list"); @@ -483,14 +573,19 @@ program } throw error; } - }); - -program - .command("ls") - .description("List active sessions") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (opts: { json?: boolean; toon?: boolean }) => { + }, +); + +const lsCommand = command( + { + name: "ls", + flags: { ...sessionFlag, ...formatFlags }, + help: { description: "List active sessions" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("ls", parsed._, 0); + const opts = parsed.flags; const format = resolveFormat(opts); const sock = socketPath(); // No daemon → no sessions can exist; report it (E7) and emit an empty list @@ -521,14 +616,21 @@ program for (const s of sessions) { process.stdout.write(`${s.id} ${s.name} ${s.projectDir}\n`); } - }); - -program - .command("inspect ") - .description("Show service details") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (name: string, opts: { json?: boolean; toon?: boolean }) => { + }, +); + +const inspectCommand = command( + { + name: "inspect", + parameters: [""], + flags: { ...sessionFlag, ...formatFlags }, + help: { description: "Show service details" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("inspect", parsed._, 1); + const name = parsed._.service; + const opts = parsed.flags; try { await withDaemon(async (ipc) => { const res = await ipc.request("services.details", { name }); @@ -562,7 +664,8 @@ program } throw error; } - }); + }, +); // --- New Commands --- @@ -576,12 +679,26 @@ const SERVICE_COLORS = [ ]; const RESET = "\x1b[0m"; -program - .command("logs [services...]") - .description("Dump log buffer. -f to stream live") - .option("-f, --follow", "Stream live logs") - .option("--tail ", "Number of lines to show", "100") - .action(async (services: string[], opts: { follow?: boolean; tail: string }) => { +const logsCommand = command( + { + name: "logs", + parameters: ["[services...]"], + flags: { + ...sessionFlag, + follow: { type: Boolean, alias: "f", description: "Stream live logs" }, + tail: { + type: String, + default: "100", + placeholder: "", + description: "Number of lines to show", + }, + }, + help: { description: "Dump log buffer. -f to stream live" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + const { services } = parsed._; + const opts = parsed.flags; const tail = parsePositiveInt(opts.tail); if (tail === null) { process.stderr.write(`Invalid --tail value "${opts.tail}": expected a positive integer.\n`); @@ -675,14 +792,21 @@ program } throw error; } - }); - -program - .command("run ") - .description("Run a task") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (key: string, opts: { json?: boolean; toon?: boolean }) => { + }, +); + +const runCommand = command( + { + name: "run", + parameters: [""], + flags: { ...sessionFlag, ...formatFlags }, + help: { description: "Run a task" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("run", parsed._, 1); + const key = parsed._.task; + const opts = parsed.flags; try { const format = resolveFormat(opts); await withDaemon(async (ipc) => { @@ -711,13 +835,22 @@ program } throw error; } - }); - -program - .command("events") - .description("Stream daemon events as ndjson") - .option("--filter ", "Filter events by type (regex)") - .action(async (opts: { filter?: string }) => { + }, +); + +const eventsCommand = command( + { + name: "events", + flags: { + ...sessionFlag, + filter: { type: String, placeholder: "", description: "Filter events by type (regex)" }, + }, + help: { description: "Stream daemon events as ndjson" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("events", parsed._, 0); + const opts = parsed.flags; const sock = socketPath(); if (!isDaemonRunning()) { @@ -778,15 +911,23 @@ program resolve(); }); }); - }); - -program - .command("config") - .description("Validate and print resolved config") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .option("--path", "Print config file path only") - .action(async (opts: { json?: boolean; toon?: boolean; path?: boolean }) => { + }, +); + +const configCommand = command( + { + name: "config", + flags: { + ...sessionFlag, + ...formatFlags, + path: { type: Boolean, description: "Print config file path only" }, + }, + help: { description: "Validate and print resolved config" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("config", parsed._, 0); + const opts = parsed.flags; const configPath = discoverConfig(process.cwd()); if (!configPath) { process.stderr.write("No config found. Run `zaps init` to create one.\n"); @@ -851,12 +992,18 @@ program process.stdout.write(` ${key} ${t.name}\n`); } } - }); - -program - .command("prime-agent", { hidden: !isCodingAgent() }) - .description("Print project overview for AI agent priming") - .action(async () => { + }, +); + +const primeAgentCommand = command( + { + name: "prime-agent", + flags: { ...sessionFlag }, + help: { description: "Print project overview for AI agent priming" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("prime-agent", parsed._, 0); try { await withDaemon(async (ipc) => { const [svcRes, taskRes] = await Promise.all([ @@ -905,12 +1052,18 @@ program } throw error; } - }); - -program - .command("reload") - .description("Reload config for running session") - .action(async () => { + }, +); + +const reloadCommand = command( + { + name: "reload", + flags: { ...sessionFlag }, + help: { description: "Reload config for running session" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("reload", parsed._, 0); try { await withDaemon(async (ipc) => { const res = await ipc.request("session.reload"); @@ -926,14 +1079,20 @@ program } throw error; } - }); + }, +); // --- Kept As-Is --- -program - .command("init") - .description("Create a starter .zaps.mts config") - .action(async () => { +const initCommand = command( + { + name: "init", + flags: { ...sessionFlag }, + help: { description: "Create a starter .zaps.mts config" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("init", parsed._, 0); const cwd = process.cwd(); const existing = discoverConfig(cwd); if (existing) { @@ -942,12 +1101,18 @@ program } const written = await scaffoldConfig(cwd); process.stdout.write(`Created ${written}\n`); - }); - -program - .command("attach") - .description("Attach to a running zaps session") - .action(async () => { + }, +); + +const attachCommand = command( + { + name: "attach", + flags: { ...sessionFlag }, + help: { description: "Attach to a running zaps session" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("attach", parsed._, 0); if (!getEnv("TMUX")) { process.stderr.write("zaps must be run from inside a tmux session.\n"); process.exit(1); @@ -980,14 +1145,19 @@ program } throw error; } - }); - -program - .command("tasks") - .description("List tasks") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (opts: { json?: boolean; toon?: boolean }) => { + }, +); + +const tasksCommand = command( + { + name: "tasks", + flags: { ...sessionFlag, ...formatFlags }, + help: { description: "List tasks" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("tasks", parsed._, 0); + const opts = parsed.flags; try { await withDaemon(async (ipc) => { const res = await ipc.request("tasks.list"); @@ -1017,22 +1187,45 @@ program } throw error; } - }); - -program - .command("ui", { hidden: true }) - .description("Run zaps TUI (internal)") - .option("--start", "Start services before rendering TUI") - .requiredOption("--session ", "Daemon session ID") - .requiredOption("--socket ", "Daemon socket path") - .action(async (opts: { start?: boolean; session: string; socket: string }) => { + }, +); + +const uiCommand = command( + { + name: "ui", + flags: { + start: { type: Boolean, description: "Start services before rendering TUI" }, + session: { type: String, placeholder: "", description: "Daemon session ID" }, + socket: { type: String, placeholder: "", description: "Daemon socket path" }, + }, + help: { description: "Run zaps TUI (internal)" }, + }, + async (parsed) => { + rejectExcessArgs("ui", parsed._, 0); + const opts = parsed.flags; + if (opts.session === undefined) { + process.stderr.write("error: required option '--session ' not specified\n"); + process.exit(1); + } + if (opts.socket === undefined) { + process.stderr.write("error: required option '--socket ' not specified\n"); + process.exit(1); + } await runTui({ sessionId: opts.session, socketPath: opts.socket, autoStart: opts.start }); - }); - -program - .command("exec-service ", { hidden: true }) - .description("Execute a service via wrapper (internal)") - .action(async (name: string) => { + }, +); + +const execServiceCommand = command( + { + name: "exec-service", + parameters: [""], + flags: { ...sessionFlag }, + help: { description: "Execute a service via wrapper (internal)" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("exec-service", parsed._, 1); + const { name } = parsed._; const session = globalSession(); if (!session) { process.stderr.write("Error: -s/--session is required for exec-service\n"); @@ -1040,12 +1233,20 @@ program } const { execService } = await import("./cli/exec-service.js"); await execService(name, session); - }); - -program - .command("exec-task ", { hidden: true }) - .description("Execute a run-in-pane task via wrapper (internal)") - .action(async (runId: string) => { + }, +); + +const execTaskCommand = command( + { + name: "exec-task", + parameters: [""], + flags: { ...sessionFlag }, + help: { description: "Execute a run-in-pane task via wrapper (internal)" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("exec-task", parsed._, 1); + const { runId } = parsed._; const session = globalSession(); if (!session) { process.stderr.write("Error: -s/--session is required for exec-task\n"); @@ -1053,35 +1254,51 @@ program } const { execTask } = await import("./cli/exec-task.js"); await execTask(runId, session); - }); + }, +); // --- Daemon management --- -const daemonCmd = program.command("daemon").description("Daemon management"); - -daemonCmd - .command("run") - .description("Run daemon in foreground (internal)") - .action(async () => { +const daemonRunCommand = command( + { + name: "run", + flags: { ...sessionFlag }, + help: { description: "Run daemon in foreground (internal)" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("run", parsed._, 0); await runDaemon(); - }); - -daemonCmd - .command("start") - .description("Start the background daemon") - .action(async () => { + }, +); + +const daemonStartCommand = command( + { + name: "start", + flags: { ...sessionFlag }, + help: { description: "Start the background daemon" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("start", parsed._, 0); if (isDaemonRunning()) { process.stdout.write("Daemon already running.\n"); return; } await ensureDaemon(resolveCommandArgv()); process.stdout.write("Daemon started.\n"); - }); - -daemonCmd - .command("stop") - .description("Stop the background daemon") - .action(async () => { + }, +); + +const daemonStopCommand = command( + { + name: "stop", + flags: { ...sessionFlag }, + help: { description: "Stop the background daemon" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("stop", parsed._, 0); if (!isDaemonRunning()) { process.stdout.write("Daemon not running.\n"); return; @@ -1113,14 +1330,19 @@ daemonCmd /* eslint-enable no-await-in-loop */ process.stdout.write(`Stopped ${sessionCount} session(s), ${serviceCount} service(s).\n`); - }); - -daemonCmd - .command("status") - .description("Show daemon status") - .option("--json", "Output as JSON") - .option("--toon", "Output as TOON") - .action(async (opts: { json?: boolean; toon?: boolean }) => { + }, +); + +const daemonStatusCommand = command( + { + name: "status", + flags: { ...sessionFlag, ...formatFlags }, + help: { description: "Show daemon status" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("status", parsed._, 0); + const opts = parsed.flags; const format = resolveFormat(opts); if (!isDaemonRunning()) { if (format !== "text") { @@ -1146,12 +1368,18 @@ daemonCmd process.stdout.write(` ${s.id} ${s.name}\n`); } } - }); - -daemonCmd - .command("ping") - .description("Check if daemon is responsive") - .action(async () => { + }, +); + +const daemonPingCommand = command( + { + name: "ping", + flags: { ...sessionFlag }, + help: { description: "Check if daemon is responsive" }, + }, + async (parsed) => { + adoptGlobalSession(parsed.flags.session); + rejectExcessArgs("ping", parsed._, 0); if (!isDaemonRunning()) { process.stderr.write("Daemon not running.\n"); process.exit(1); @@ -1163,23 +1391,182 @@ daemonCmd process.exit(1); } process.stdout.write(`${res.result as string}\n`); - }); + }, +); + +/** + * `daemon` is a nested command group; cleye commands are flat, so the group is + * dispatched as its own cleye instance on the remaining argv. + */ +function runDaemonCli(rawArgv: string[]): void { + const argv = consumeLeadingSessionFlag(rawArgv); + void cli( + { + name: "zaps daemon", + commands: [ + daemonRunCommand, + daemonStartCommand, + daemonStopCommand, + daemonStatusCommand, + daemonPingCommand, + ], + flags: { ...sessionFlag }, + help: { description: "Daemon management" }, + strictFlags: true, + }, + (parsed) => { + adoptGlobalSession(parsed.flags.session); + const [unknownCommand] = parsed._; + if (unknownCommand !== undefined) { + process.stderr.write(`error: unknown command '${unknownCommand}'\n`); + process.exit(1); + } + parsed.showHelp(); + process.exit(1); + }, + argv, + ); +} -program - .command("mcp") - .description("Start MCP server for AI tool integration") - .option("-s, --session ", "Target session (auto-detected from CWD)") - .action(async (opts: { session?: string }) => { +/** Listed in root help only; dispatch is intercepted in `runRootCli`. */ +const daemonGroupCommand = command({ + name: "daemon", + help: { description: "Daemon management" }, +}); + +const mcpCommand = command( + { + name: "mcp", + flags: { + session: { + type: String, + alias: "s", + placeholder: "", + description: "Target session (auto-detected from CWD)", + }, + }, + help: { description: "Start MCP server for AI tool integration" }, + }, + async (parsed) => { + rejectExcessArgs("mcp", parsed._, 0); const sock = socketPath(); // Session binding is resolved per tool call inside the server (E9) — not // Cached here — so an MCP server started before `zaps up`, or surviving a // Session restart, picks up the current session on the next call. const { startMcpServer } = await import("./mcp/server.js"); - await startMcpServer(sock, opts.session); - }); + await startMcpServer(sock, parsed.flags.session); + }, +); + +const rootCommands: Command[] = [ + upCommand, + downCommand, + startCommand, + stopCommand, + restartCommand, + psCommand, + lsCommand, + inspectCommand, + logsCommand, + runCommand, + eventsCommand, + configCommand, + primeAgentCommand, + reloadCommand, + initCommand, + attachCommand, + tasksCommand, + uiCommand, + execServiceCommand, + execTaskCommand, + daemonGroupCommand, + mcpCommand, +]; + +/** Commands omitted from `--help`; still invocable directly. */ +function hiddenCommandNames(): Set { + const hidden = new Set(["ui", "exec-service", "exec-task"]); + if (!isCodingAgent()) { + hidden.add("prime-agent"); + } + return hidden; +} + +function runRootCli(argv: string[]): void { + if (argv[0] === "daemon") { + runDaemonCli(argv.slice(1)); + return; + } + void cli( + { + name: "zaps", + commands: rootCommands, + flags: { + ...sessionFlag, + version: { type: Boolean, alias: "V", description: "output the version number" }, + }, + help: { + description: "Terminal session manager", + render: (nodes, renderers) => { + const hidden = hiddenCommandNames(); + for (const node of nodes) { + if (node.id === "commands") { + const section = node.data as { body: { data: { tableData: string[][] } } }; + section.body.data.tableData = section.body.data.tableData.filter( + (row) => !hidden.has(row[0]), + ); + } + } + return renderers.render(nodes); + }, + }, + strictFlags: true, + }, + (parsed) => { + if (parsed.flags.version) { + process.stdout.write(`${VERSION}\n`); + process.exit(0); + } + adoptGlobalSession(parsed.flags.session); + const [unknownCommand] = parsed._; + if (unknownCommand !== undefined) { + process.stderr.write(`error: unknown command '${unknownCommand}'\n`); + process.exit(1); + } + parsed.showHelp(); + process.exit(1); + }, + argv, + ); +} + +// Registered last (matching commander's implicit trailing `help [command]`) +// And defined after `runRootCli` so it can re-dispatch ` --help`. +const helpCommand = command( + { + name: "help", + parameters: ["[command]"], + help: { description: "display help for command" }, + }, + (parsed) => { + rejectExcessArgs("help", parsed._, 1); + const target = parsed._.command; + if (target === undefined) { + runRootCli(["--help"]); + return; + } + if (rootCommands.some((c) => c.options.name === target)) { + runRootCli([target, "--help"]); + return; + } + process.stderr.write(`error: unknown command '${target}'\n`); + process.exit(1); + }, +); +rootCommands.push(helpCommand); if (process.argv.length === 2) { process.argv.push("up"); } -program.parse(); +runRootCli(consumeLeadingSessionFlag(process.argv.slice(2))); diff --git a/test/cli.test.ts b/test/cli.test.ts index 6a94b97..b219751 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,4 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; // Mock all external dependencies before importing the module under test @@ -368,6 +373,209 @@ describe("CLI — session naming", () => { }); }); +describe("CLI — cleye parse layer (subprocess parity)", () => { + const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + const CLI_TIMEOUT = 60_000; + + let spawnSyncReal: typeof import("node:child_process").spawnSync; + let runtimeDir: string; + + beforeAll(async () => { + // The module-level vi.mock replaces node:child_process; the parse-layer + // Tests need the real spawnSync to exercise the actual CLI surface. + const actual = await vi.importActual("node:child_process"); + spawnSyncReal = actual.spawnSync; + // Isolated runtime dir so isDaemonRunning() never sees a real daemon. + runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "zaps-cli-parse-")); + }); + + afterAll(() => { + fs.rmSync(runtimeDir, { recursive: true, force: true }); + }); + + function runCli(args: string[], envOverrides: Record = {}) { + return spawnSyncReal(process.execPath, ["--import", "tsx", "src/cli.tsx", ...args], { + cwd: repoRoot, + encoding: "utf8", + timeout: CLI_TIMEOUT, + env: { + ...process.env, + CLAUDECODE: undefined, + CURSOR_TRACE_DIR: undefined, + TMUX: undefined, + ZAPS_FORMAT: undefined, + ZAPS_RUNTIME: undefined, + ZAPS_SOCKET_PATH: undefined, + XDG_RUNTIME_DIR: runtimeDir, + ...envOverrides, + }, + }); + } + + it( + "--help lists every visible command and hides internal ones", + () => { + const res = runCli(["--help"]); + expect(res.status).toBe(0); + + const visible = [ + "up", + "down", + "start", + "stop", + "restart", + "ps", + "ls", + "inspect", + "logs", + "run", + "events", + "config", + "reload", + "init", + "attach", + "tasks", + "daemon", + "mcp", + "help", + ]; + for (const name of visible) { + expect(res.stdout).toMatch(new RegExp(`^\\s{2}${name}\\s`, "m")); + } + + expect(res.stdout).not.toMatch(/^\s*ui\s/m); + expect(res.stdout).not.toContain("exec-service"); + expect(res.stdout).not.toContain("exec-task"); + expect(res.stdout).not.toContain("prime-agent"); + // Global option + version flag documented at the root + expect(res.stdout).toContain("-s, --session "); + expect(res.stdout).toContain("-V, --version"); + }, + CLI_TIMEOUT, + ); + + it( + "prime-agent is listed exactly once when a coding-agent env var is set", + () => { + const res = runCli(["--help"], { CLAUDECODE: "1", CURSOR_TRACE_DIR: "/tmp" }); + expect(res.status).toBe(0); + expect(res.stdout.split("prime-agent").length - 1).toBe(1); + }, + CLI_TIMEOUT, + ); + + it( + "--version and -V print the version shape and exit 0", + () => { + const long = runCli(["--version"]); + expect(long.status).toBe(0); + expect(long.stdout).toBe("dev (source) built from source\n"); + + const short = runCli(["-V"]); + expect(short.status).toBe(0); + expect(short.stdout).toBe("dev (source) built from source\n"); + }, + CLI_TIMEOUT, + ); + + it( + "daemon --help lists the nested subcommand group", + () => { + const res = runCli(["daemon", "--help"]); + expect(res.status).toBe(0); + for (const name of ["run", "start", "stop", "status", "ping"]) { + expect(res.stdout).toMatch(new RegExp(`^\\s{2}${name}\\s`, "m")); + } + }, + CLI_TIMEOUT, + ); + + it( + "unknown commands exit 1 with an error", + () => { + const res = runCli(["bogus"]); + expect(res.status).toBe(1); + expect(res.stderr).toContain("unknown command 'bogus'"); + + const nested = runCli(["daemon", "bogus"]); + expect(nested.status).toBe(1); + expect(nested.stderr).toContain("unknown command 'bogus'"); + }, + CLI_TIMEOUT, + ); + + it( + "ui without its required options exits 1 with a clear message", + () => { + const res = runCli(["ui"]); + expect(res.status).toBe(1); + expect(res.stderr).toContain("required option '--session ' not specified"); + + const withSession = runCli(["ui", "--session", "abc"]); + expect(withSession.status).toBe(1); + expect(withSession.stderr).toContain("required option '--socket ' not specified"); + }, + CLI_TIMEOUT, + ); + + it( + "exec-service/exec-task keep the verbatim -s/--session runtime check", + () => { + const service = runCli(["exec-service", "api"]); + expect(service.status).toBe(1); + expect(service.stderr).toContain("Error: -s/--session is required for exec-service"); + + const task = runCli(["exec-task", "run-1"]); + expect(task.status).toBe(1); + expect(task.stderr).toContain("Error: -s/--session is required for exec-task"); + }, + CLI_TIMEOUT, + ); + + it( + "global -s/--session is accepted before and after the command name", + () => { + // Daemon is isolated away, so reaching "Daemon not running." proves the + // Flag was parsed (an unknown flag would error differently via strictFlags). + const before = runCli(["-s", "foo", "ps"]); + expect(before.status).toBe(1); + expect(before.stderr).toContain("Daemon not running."); + + const after = runCli(["ps", "-s", "foo"]); + expect(after.status).toBe(1); + expect(after.stderr).toContain("Daemon not running."); + }, + CLI_TIMEOUT, + ); + + it( + "logs --help documents -f/--follow and the --tail default", + () => { + const res = runCli(["logs", "--help"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("-f, --follow"); + expect(res.stdout).toContain("--tail "); + expect(res.stdout).toContain('(default: "100")'); + expect(res.stdout).toContain("[services...]"); + }, + CLI_TIMEOUT, + ); + + it( + "unknown flags and excess arguments exit 1", + () => { + const unknownFlag = runCli(["ps", "--bogus"]); + expect(unknownFlag.status).toBe(1); + expect(unknownFlag.stderr).toContain("Unknown flag: --bogus"); + + const excess = runCli(["ps", "extra"]); + expect(excess.status).toBe(1); + expect(excess.stderr).toContain("too many arguments for 'ps'"); + }, + CLI_TIMEOUT, + ); +}); + describe("CLI — buildDeps", () => { it("returns object with all required ServiceManagerDeps keys", () => { // Verify that the mock deps match the interface shape From fc80d27cd0487c614d1449b3330db76f6c3f9045 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 3 Jul 2026 16:19:54 +0200 Subject: [PATCH 4/5] chore(deps): move tsx to devDependencies --- package.json | 2 +- pnpm-lock.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index d4f9679..0093a92 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "jiti": "^2.7.0", "open": "^11.0.0", "react": "^19.2.7", - "tsx": "^4.22.4", "yoga-layout": "^3.2.1", "zod": "^4.4.3" }, @@ -79,6 +78,7 @@ "oxlint-tsgolint": "^0.23.0", "react-devtools-core": "^7.0.1", "testcontainers": "^12.0.1", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35162ea..b4f05fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ importers: react: specifier: ^19.2.7 version: 19.2.7 - tsx: - specifier: ^4.22.4 - version: 4.22.4 yoga-layout: specifier: ^3.2.1 version: 3.2.1 @@ -82,6 +79,9 @@ importers: testcontainers: specifier: ^12.0.1 version: 12.0.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: specifier: ^6.0.3 version: 6.0.3 From ec3ad8a9adb37fe68dcaf08b622a033f6c3d0e27 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 3 Jul 2026 16:31:17 +0200 Subject: [PATCH 5/5] test(cli): add parity sweep tests for cleye migration --- test/cli.test.ts | 118 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 4 deletions(-) diff --git a/test/cli.test.ts b/test/cli.test.ts index b219751..bb92dec 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -393,9 +394,14 @@ describe("CLI — cleye parse layer (subprocess parity)", () => { fs.rmSync(runtimeDir, { recursive: true, force: true }); }); - function runCli(args: string[], envOverrides: Record = {}) { - return spawnSyncReal(process.execPath, ["--import", "tsx", "src/cli.tsx", ...args], { - cwd: repoRoot, + // Absolute specifiers so the CLI can be spawned from any cwd (e.g. a config- + // Free temp dir for the zero-argument default-command test). + const tsxImport = pathToFileURL(createRequire(import.meta.url).resolve("tsx")).href; + const cliEntry = path.join(repoRoot, "src/cli.tsx"); + + function runCli(args: string[], envOverrides: Record = {}, cwd = repoRoot) { + return spawnSyncReal(process.execPath, ["--import", tsxImport, cliEntry, ...args], { + cwd, encoding: "utf8", timeout: CLI_TIMEOUT, env: { @@ -574,6 +580,110 @@ describe("CLI — cleye parse layer (subprocess parity)", () => { }, CLI_TIMEOUT, ); + + it( + "every command's --help exits 0, hidden and daemon subcommands included", + () => { + const rootLevel = [ + "up", + "down", + "start", + "stop", + "restart", + "ps", + "ls", + "inspect", + "logs", + "run", + "events", + "config", + "reload", + "init", + "attach", + "tasks", + "mcp", + "help", + // Hidden commands stay invocable directly, help included. + "ui", + "exec-service", + "exec-task", + "prime-agent", + ]; + for (const name of rootLevel) { + const res = runCli([name, "--help"]); + expect(res.status, `${name} --help exit code`).toBe(0); + expect(res.stdout, `${name} --help heading`).toContain(`zaps ${name}`); + } + for (const sub of ["run", "start", "stop", "status", "ping"]) { + const res = runCli(["daemon", sub, "--help"]); + expect(res.status, `daemon ${sub} --help exit code`).toBe(0); + expect(res.stdout, `daemon ${sub} --help heading`).toContain(`zaps daemon ${sub}`); + } + }, + CLI_TIMEOUT, + ); + + it( + "bare `zaps daemon` prints the group help to stdout and exits 1", + () => { + const res = runCli(["daemon"]); + expect(res.status).toBe(1); + expect(res.stdout).toContain("Daemon management"); + for (const name of ["run", "start", "stop", "status", "ping"]) { + expect(res.stdout).toMatch(new RegExp(`^\\s{2}${name}\\s`, "m")); + } + }, + CLI_TIMEOUT, + ); + + it( + "variadic [services...] accepts zero (= all) and multiple values alike", + () => { + // Daemon is isolated away: reaching "Daemon not running." proves the + // Argv shape parsed cleanly for both the empty and multi-value forms. + for (const cmd of ["start", "stop", "restart", "logs"]) { + const none = runCli([cmd]); + expect(none.status, `${cmd} (no services) exit code`).toBe(1); + expect(none.stderr, `${cmd} (no services) stderr`).toContain("Daemon not running."); + + const many = runCli([cmd, "api", "db"]); + expect(many.status, `${cmd} api db exit code`).toBe(1); + expect(many.stderr, `${cmd} api db stderr`).toContain("Daemon not running."); + } + }, + CLI_TIMEOUT, + ); + + it( + "zero-argument `zaps` behaves exactly like `zaps up`", + () => { + // A config-free temp cwd makes both paths fail identically and fast. + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "zaps-cli-noconfig-")); + try { + const bare = runCli([], {}, emptyDir); + const explicit = runCli(["up"], {}, emptyDir); + expect(bare.status).toBe(1); + expect(bare.stderr).toBe("No config found. Run `zaps init` to create one.\n"); + expect(explicit.status).toBe(bare.status); + expect(explicit.stderr).toBe(bare.stderr); + expect(explicit.stdout).toBe(bare.stdout); + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }, + CLI_TIMEOUT, + ); + + it( + "mcp --help documents its local -s/--session option", + () => { + const res = runCli(["mcp", "--help"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("-s, --session "); + expect(res.stdout).toContain("Target session (auto-detected from CWD)"); + }, + CLI_TIMEOUT, + ); }); describe("CLI — buildDeps", () => {