Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f32b34a
feat(cli): --output <path> and --output-format raw|json to save a res…
cliffhall Oct 5, 2026
357614b
feat(cli): add -q/--quiet to print only the result or the error (#2435)
cliffhall Oct 5, 2026
cb54c84
feat(cli): emit bash/zsh/fish completion scripts with --completion (#…
cliffhall Oct 5, 2026
1c0d181
fix(cli): read stored-auth tokens from the active issuer slot
cliffhall Oct 5, 2026
1851809
fix(cli): drop Commander's usage diagnostic under --quiet (#2435)
cliffhall Oct 5, 2026
37452a9
fix(cli): keep core's secret-store notice off stderr under --quiet (#…
cliffhall Oct 5, 2026
9a6edc3
fix(cli): complete --opt=value in bash and zsh completion
cliffhall Oct 5, 2026
d6cc7fb
feat(cli): servers/add, servers/edit, servers/remove catalog writes (…
cliffhall Oct 5, 2026
f003441
fix(cli): mute console.warn for the whole --quiet run (#2435)
cliffhall Oct 5, 2026
a4d03ba
fix(core): own-property server lookup so --server constructor reports…
cliffhall Oct 5, 2026
e2b1db9
fix(cli): refuse servers/edit --rename on an in-memory secret store
cliffhall Oct 5, 2026
7f616de
fix(cli): detect -q inside a combined short-option cluster (#2435)
cliffhall Oct 5, 2026
43ae67c
fix(cli): decode --output raw binary strictly, refusing invalid base6…
cliffhall Oct 5, 2026
0a1c0d4
fix(cli): decide --quiet from Commander's parsed state, not an argv s…
cliffhall Oct 5, 2026
0fe5c2a
fix(cli): reject stdio-only flags on a URL target and a blank --rename
cliffhall Oct 5, 2026
1376303
fix(cli): refuse a blank --config and treat malformed entries as not …
cliffhall Oct 5, 2026
e5c2124
feat(cli,tui): --skill-catalog-max-skills/--skill-catalog-max-bytes f…
cliffhall Oct 5, 2026
0a23e9d
Merge remote-tracking branch 'origin/v2/fix/2537-own-property-server-…
cliffhall Oct 5, 2026
27c6261
Merge remote-tracking branch 'origin/v2/fix/2517-cli-stored-auth-byis…
cliffhall Oct 5, 2026
abb7759
Merge remote-tracking branch 'origin/v2/feat/2435-cli-quiet-flag' int…
cliffhall Oct 5, 2026
604d7eb
Merge remote-tracking branch 'origin/v2/feat/2434-cli-shell-completio…
cliffhall Oct 5, 2026
2d470e6
Merge remote-tracking branch 'origin/v2/feat/2433-cli-servers-write' …
cliffhall Oct 5, 2026
bc4b292
Merge remote-tracking branch 'origin/v2/feat/2431-cli-output-file' in…
cliffhall Oct 5, 2026
801c7a6
Merge remote-tracking branch 'origin/v2/feat/2420-skill-catalog-budge…
cliffhall Oct 5, 2026
58a58a9
fix(core): name the decimal-digit and safe-integer limits in the budg…
cliffhall Oct 5, 2026
5abebac
chore: realign CLI test table after rollup merge (#2568)
cliffhall Oct 5, 2026
93cf3ed
docs(tui): say the skills budget flags do not bound the one-skill Ski…
cliffhall Oct 5, 2026
783e3e9
test(tui): cover runTui forwarding of --skill-catalog-max-* to the lo…
cliffhall Oct 5, 2026
33aa0cd
Merge remote-tracking branch 'origin/v2/feat/2420-skill-catalog-budge…
cliffhall Oct 5, 2026
f8b51d0
fix(cli): require --verify for --skill-catalog-max-* (#2420)
cliffhall Oct 5, 2026
287f3f7
Merge remote-tracking branch 'origin/v2/feat/2420-skill-catalog-budge…
cliffhall Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions clients/cli/README.md

Large diffs are not rendered by default.

56 changes: 29 additions & 27 deletions clients/cli/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,35 @@ npm run validate # format:check && lint && typecheck && test

## Test files

| File | Focus |
| --------------------------------------- | ------------------------------------------- |
| `cli.test.ts` | Core connect / method / config matrix |
| `tools.test.ts` | `tools/call` argument coercion |
| `headers.test.ts` | `--header` merging |
| `metadata.test.ts` | `--metadata` / `--tool-metadata` |
| `methods.test.ts` | Method allow-list / rejection |
| `app-info.test.ts` | `--app-info` probe paths |
| `format-json.test.ts` | `--format json` envelopes |
| `format-output.test.ts` | Text/json writers |
| `emit-result.test.ts` | Result emission helpers |
| `method-types.test.ts` | `ONE_SHOT_METHODS` / guards |
| `run-method.test.ts` | Handler dispatch against a real test server |
| `run-method-mocks.test.ts` | Handler edge cases with mocks |
| `servers-list.test.ts` | `servers/list` / `servers/show` + redaction |
| `cliOAuth.test.ts` | Connect / mid-RPC OAuth recovery |
| `cli-oauth-navigation.test.ts` | OSC 8, arm/disarm, `MCP_AUTO_OPEN_ENABLED` |
| `open-url.test.ts` | `open` package wrapper |
| `oauth-runner.test.ts` | Runner client-config / CIMD flags |
| `oauth-interactive.test.ts` | Loopback callback OAuth (in-process) |
| `stored-auth.test.ts` | `--use-stored-auth` / handoff / wait |
| `clear-stored-auth-for-relogin.test.ts` | Dual-key `--relogin` clear |
| `programmatic-ergonomics.test.ts` | Flag conflicts, timeouts, ergonomics |
| `error-handler.test.ts` | Exit codes / error shaping |
| `style.test.ts` | ANSI / OSC 8 helpers |
| `e2e.test.ts` | Out-of-process binary smoke |
| `helpers/assertions.test.ts` | Assertion helpers |
| File | Focus |
| --------------------------------------- | --------------------------------------------------------------------------------------- |
| `cli.test.ts` | Core connect / method / config matrix |
| `tools.test.ts` | `tools/call` argument coercion |
| `headers.test.ts` | `--header` merging |
| `metadata.test.ts` | `--metadata` / `--tool-metadata` |
| `methods.test.ts` | Method allow-list / rejection |
| `app-info.test.ts` | `--app-info` probe paths |
| `format-json.test.ts` | `--format json` envelopes |
| `format-output.test.ts` | Text/json writers |
| `emit-result.test.ts` | Result emission helpers |
| `output-file.test.ts` | `--output` / `--output-format` (#2431) |
| `method-types.test.ts` | `ONE_SHOT_METHODS` / guards |
| `run-method.test.ts` | Handler dispatch against a real test server |
| `run-method-mocks.test.ts` | Handler edge cases with mocks |
| `servers-list.test.ts` | `servers/list` / `servers/show` + redaction |
| `servers-write.test.ts` | `servers/add` / `servers/edit` / `servers/remove` against a temp catalog + secret store |
| `cliOAuth.test.ts` | Connect / mid-RPC OAuth recovery |
| `cli-oauth-navigation.test.ts` | OSC 8, arm/disarm, `MCP_AUTO_OPEN_ENABLED` |
| `open-url.test.ts` | `open` package wrapper |
| `oauth-runner.test.ts` | Runner client-config / CIMD flags |
| `oauth-interactive.test.ts` | Loopback callback OAuth (in-process) |
| `stored-auth.test.ts` | `--use-stored-auth` / handoff / wait |
| `clear-stored-auth-for-relogin.test.ts` | Dual-key `--relogin` clear |
| `programmatic-ergonomics.test.ts` | Flag conflicts, timeouts, ergonomics |
| `error-handler.test.ts` | Exit codes / error shaping |
| `style.test.ts` | ANSI / OSC 8 helpers |
| `e2e.test.ts` | Out-of-process binary smoke |
| `helpers/assertions.test.ts` | Assertion helpers |

## Helpers

Expand Down
105 changes: 104 additions & 1 deletion clients/cli/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, it, expect, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "./helpers/cli-runner.js";
Expand Down Expand Up @@ -85,6 +85,109 @@ describe("CLI Tests", () => {
expect(toolNames).toContain("get_annotated_message");
});

// #2435. In-process, a stdio server's own stderr goes straight to the
// worker's fd 2 rather than through the captured `process.stderr.write`,
// so the child-stderr half is asserted out of process in e2e.test.ts.
it.each(["-q", "--quiet"])(
"%s prints only the result payload",
async (flag) => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
flag,
"--method",
"tools/list",
]);

expectCliSuccess(result);
expect(expectValidJson(result)).toHaveProperty("tools");
expect(result.stderr).toBe("");
},
);

// A usage error makes Commander print its own `error: …` line during
// `parse()`, before the envelope. Under `--quiet` that line is dropped and
// stderr is the envelope alone (Copilot on #2576).
it("drops Commander's usage diagnostic under --quiet, keeping only the envelope", async () => {
const loud = await runCli([NO_SERVER_SENTINEL, "--method"]);
expectCliFailure(loud);
expect(loud.stderr).toMatch(/^error: option '--method <method>'/);

const quiet = await runCli([NO_SERVER_SENTINEL, "-q", "--method"]);
expectCliFailure(quiet);
const lines = quiet.stderr.trimEnd().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0]!)).toMatchObject({
error: { message: expect.stringMatching(/--method <method>/) },
});
});

// Core reports advisories with `console.warn` from many places — here
// `cleanRoots()` on a hand-edited entry. Vitest replaces `console`, so the
// captured stderr cannot show it; the spy is what observes the channel.
// Under `--quiet` the run swaps `console.warn` out and restores it after
// (Copilot on #2576).
it("mutes core's console.warn advisories for a --quiet run, and restores console.warn after", async () => {
const { command, args } = getTestMcpServerCommand();
const dir = mkdtempSync(join(tmpdir(), "cli-quiet-roots-"));
const configPath = join(dir, "mcp.json");
// A root with no `uri` is dropped with a warning (core/mcp/serverList.ts).
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
s: { type: "stdio", command, args, roots: [{ name: "no-uri" }] },
},
}),
);
const run = (extra: string[]) =>
runCli(["--config", configPath, ...extra, "--method", "tools/list"]);
try {
const loud = vi.spyOn(console, "warn").mockImplementation(() => {});
expectCliSuccess(await run([]));
expect(loud).toHaveBeenCalledWith(
"Dropping root without a string `uri`:",
expect.anything(),
);
loud.mockRestore();

const quiet = vi.spyOn(console, "warn").mockImplementation(() => {});
expectCliSuccess(await run(["-q"]));
expect(quiet).not.toHaveBeenCalled();
expect(console.warn).toBe(quiet);
quiet.mockRestore();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

// Quiet is read from Commander's parsed state, so a combined `-qe` counts
// and a `-q` that is another option's value does not (Copilot on #2576).
it("applies the pre-parse --quiet handling when -q is combined with -e", async () => {
const result = await runCli([
NO_SERVER_SENTINEL,
"-qe",
"A=1",
"--method",
]);
expectCliFailure(result);
const lines = result.stderr.trimEnd().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0]!)).toHaveProperty("error");
});

it("does not treat -q as the flag when it is another option's value", async () => {
const result = await runCli([
NO_SERVER_SENTINEL,
"--client-secret",
"-q",
"--method",
]);
expectCliFailure(result);
expect(result.stderr).toMatch(/^error: option '--method <method>'/);
});

it("should fail with nonexistent method", async () => {
const result = await runCli([
NO_SERVER_SENTINEL,
Expand Down
123 changes: 123 additions & 0 deletions clients/cli/__tests__/cliOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,4 +1184,127 @@ describe("cliOAuth", () => {
}
}
});

// #2435: `--quiet` drops the status lines, never the prompts a human has to
// act on. The step-up [y/N] still prints, because the flow cannot finish
// without an answer to it.
describe("--quiet", () => {
const QUIET_CALLBACK = CALLBACK_URL_CONFIG;
function fakeClient(connect = vi.fn().mockResolvedValue(undefined)) {
return {
connect,
disconnect: vi.fn().mockResolvedValue(undefined),
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
};
}

it("runCliInteractiveOAuth writes no success line", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);

await runCliInteractiveOAuth(
fakeClient(),
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
{ quiet: true },
);

expect(stderrSpy).not.toHaveBeenCalled();
});

it("withCliAuthRecoveryRetry writes no retry line after AuthRecoveryRequired, but keeps the step-up prompt", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const fn = vi
.fn()
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
),
)
.mockResolvedValueOnce("ok");

const result = await withCliAuthRecoveryRetry(
fakeClient(),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
makeFakeServerSettings(),
fn,
{ ...INTERACTIVE, confirmStepUp: async () => true, quiet: true },
);

expect(result).toBe("ok");
const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
expect(written).toContain("Proceed with step-up authorization? [y/N]");
expect(written).not.toContain("Authorization complete");
});

it("withCliAuthRecoveryRetry writes no retry line after a plain 401", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("RPC failed (401)"))
.mockResolvedValueOnce("ok");

const result = await withCliAuthRecoveryRetry(
fakeClient(),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
undefined,
fn,
{ ...INTERACTIVE, quiet: true },
);

expect(result).toBe("ok");
expect(stderrSpy).not.toHaveBeenCalled();
});

it("connectInspectorWithOAuth writes no success line after a 401 on connect", async () => {
vi.spyOn(
runnerInteractive,
"runRunnerInteractiveOAuth",
).mockResolvedValue({ kind: "success" });
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const connect = vi
.fn()
.mockRejectedValueOnce(new Error("RPC failed (401)"))
.mockResolvedValue(undefined);

await connectInspectorWithOAuth(
fakeClient(connect),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
QUIET_CALLBACK,
undefined,
{ ...INTERACTIVE, quiet: true },
);

expect(connect).toHaveBeenCalledTimes(2);
expect(stderrSpy).not.toHaveBeenCalled();
});
});
});
Loading
Loading