diff --git a/src/cli-core.ts b/src/cli-core.ts index 76610f9f..e37598c0 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -9,7 +9,9 @@ import { addPromptInputOption, addSessionNameOption, addSessionOption, + parseAllowedTools, parseHistoryLimit, + parseMaxTurns, parseNonEmptyValue, parseSessionName, parseTtlSeconds, @@ -145,7 +147,7 @@ function emitJsonResult(format: OutputFormat, payload: unknown): boolean { return true; } -export { parseTtlSeconds }; +export { parseAllowedTools, parseMaxTurns, parseTtlSeconds }; export { formatPromptSessionBannerLine } from "./cli/output-render.js"; type SessionModule = typeof import("./session.js"); @@ -333,6 +335,11 @@ async function handleExec( suppressSdkConsoleErrors: outputPolicy.suppressSdkConsoleErrors, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, + sessionOptions: { + model: globalFlags.model, + allowedTools: globalFlags.allowedTools, + maxTurns: globalFlags.maxTurns, + }, }); applyPermissionExitCode(result); @@ -608,6 +615,11 @@ async function handleSessionsNew( authPolicy: globalFlags.authPolicy, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, + sessionOptions: { + model: globalFlags.model, + allowedTools: globalFlags.allowedTools, + maxTurns: globalFlags.maxTurns, + }, }); printCreatedSessionBanner(created, agent.agentName, globalFlags.format, globalFlags.jsonStrict); @@ -641,6 +653,11 @@ async function handleSessionsEnsure( authPolicy: globalFlags.authPolicy, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, + sessionOptions: { + model: globalFlags.model, + allowedTools: globalFlags.allowedTools, + maxTurns: globalFlags.maxTurns, + }, }); if (result.created) { @@ -1292,6 +1309,9 @@ function detectAgentToken(argv: string[]): AgentTokenScan { token === "--auth-policy" || token === "--non-interactive-permissions" || token === "--format" || + token === "--model" || + token === "--allowed-tools" || + token === "--max-turns" || token === "--timeout" || token === "--ttl" || token === "--file" @@ -1305,6 +1325,9 @@ function detectAgentToken(argv: string[]): AgentTokenScan { token.startsWith("--auth-policy=") || token.startsWith("--non-interactive-permissions=") || token.startsWith("--format=") || + token.startsWith("--model=") || + token.startsWith("--allowed-tools=") || + token.startsWith("--max-turns=") || token.startsWith("--json-strict=") || token.startsWith("--timeout=") || token.startsWith("--ttl=") || diff --git a/src/cli.ts b/src/cli.ts index 52995682..ab81ac23 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url"; import { main } from "./cli-core.js"; export { formatPromptSessionBannerLine } from "./cli-core.js"; -export { parseTtlSeconds } from "./cli/flags.js"; +export { parseAllowedTools, parseMaxTurns, parseTtlSeconds } from "./cli/flags.js"; function isCliEntrypoint(argv: string[]): boolean { const entry = argv[1]; diff --git a/src/cli/flags.ts b/src/cli/flags.ts index f675802b..93a8529b 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -34,6 +34,9 @@ export type GlobalFlags = PermissionFlags & { ttl: number; verbose?: boolean; format: OutputFormat; + model?: string; + allowedTools?: string[]; + maxTurns?: number; }; export type PromptFlags = { @@ -125,6 +128,30 @@ export function parseHistoryLimit(value: string): number { return parsed; } +export function parseAllowedTools(value: string): string[] { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return []; + } + + const items = trimmed.split(",").map((item) => item.trim()); + if (items.some((item) => item.length === 0)) { + throw new InvalidArgumentError( + "Allowed tools must be a comma-separated list without empty entries", + ); + } + + return items; +} + +export function parseMaxTurns(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError("Max turns must be a positive integer"); + } + return parsed; +} + export function resolvePermissionMode( flags: PermissionFlags, defaultMode: PermissionMode, @@ -165,6 +192,13 @@ export function addGlobalFlags(command: Command): Command { parseNonInteractivePermissionPolicy, ) .option("--format ", "Output format: text, json, quiet", parseOutputFormat) + .option("--model ", "Agent model id") + .option( + "--allowed-tools ", + 'Allowed tool names as a comma-separated list (use "" for no tools)', + parseAllowedTools, + ) + .option("--max-turns ", "Maximum turns for the session", parseMaxTurns) .option( "--json-strict", "Strict JSON mode: requires --format json and suppresses non-JSON stderr output", @@ -247,6 +281,9 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig) ttl: opts.ttl ?? config.ttlMs ?? DEFAULT_QUEUE_OWNER_TTL_MS, verbose, format, + model: typeof opts.model === "string" ? parseNonEmptyValue("Model", opts.model) : undefined, + allowedTools: Array.isArray(opts.allowedTools) ? opts.allowedTools : undefined, + maxTurns: typeof opts.maxTurns === "number" ? opts.maxTurns : undefined, approveAll: opts.approveAll ? true : undefined, approveReads: opts.approveReads ? true : undefined, denyAll: opts.denyAll ? true : undefined, diff --git a/src/client.ts b/src/client.ts index a15045f2..b084f22f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -480,6 +480,35 @@ function readEnvCredential(methodId: string): string | undefined { return undefined; } +function buildClaudeCodeOptionsMeta( + options: AcpClientOptions["sessionOptions"], +): Record | undefined { + if (!options) { + return undefined; + } + + const claudeCodeOptions: Record = {}; + if (typeof options.model === "string" && options.model.trim().length > 0) { + claudeCodeOptions.model = options.model; + } + if (Array.isArray(options.allowedTools)) { + claudeCodeOptions.allowedTools = [...options.allowedTools]; + } + if (typeof options.maxTurns === "number") { + claudeCodeOptions.maxTurns = options.maxTurns; + } + + if (Object.keys(claudeCodeOptions).length === 0) { + return undefined; + } + + return { + claudeCode: { + options: claudeCodeOptions, + }, + }; +} + function buildAgentEnvironment( authCredentials: Record | undefined, ): NodeJS.ProcessEnv { @@ -881,6 +910,7 @@ export class AcpClient { const createPromise = connection.newSession({ cwd: asAbsoluteCwd(cwd), mcpServers: [], + _meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions), }); result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) diff --git a/src/session-runtime.ts b/src/session-runtime.ts index eff95989..3ea86324 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -94,6 +94,12 @@ type TimedRunOptions = { timeoutMs?: number; }; +export type SessionAgentOptions = { + model?: string; + allowedTools?: string[]; + maxTurns?: number; +}; + export type RunOnceOptions = { agentCommand: string; cwd: string; @@ -105,6 +111,7 @@ export type RunOnceOptions = { outputFormatter: OutputFormatter; suppressSdkConsoleErrors?: boolean; verbose?: boolean; + sessionOptions?: SessionAgentOptions; } & TimedRunOptions; export type SessionCreateOptions = { @@ -116,6 +123,7 @@ export type SessionCreateOptions = { authCredentials?: Record; authPolicy?: AuthPolicy; verbose?: boolean; + sessionOptions?: SessionAgentOptions; } & TimedRunOptions; export type SessionSendOptions = { @@ -144,6 +152,7 @@ export type SessionEnsureOptions = { authPolicy?: AuthPolicy; verbose?: boolean; walkBoundary?: string; + sessionOptions?: SessionAgentOptions; } & TimedRunOptions; export type SessionCancelOptions = { @@ -614,6 +623,7 @@ export async function runOnce(options: RunOnceOptions): Promise suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, verbose: options.verbose, onAcpOutputMessage: (_direction, message) => output.onAcpMessage(message), + sessionOptions: options.sessionOptions, }); try { @@ -659,6 +669,7 @@ export async function createSession(options: SessionCreateOptions): Promise void; onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; onSessionUpdate?: (notification: SessionNotification) => void; diff --git a/test/cli.test.ts b/test/cli.test.ts index 12e94dd7..97c85fce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -8,7 +8,12 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { InvalidArgumentError } from "commander"; -import { formatPromptSessionBannerLine, parseTtlSeconds } from "../src/cli.js"; +import { + formatPromptSessionBannerLine, + parseAllowedTools, + parseMaxTurns, + parseTtlSeconds, +} from "../src/cli.js"; import { serializeSessionRecordForDisk } from "../src/session-persistence.js"; import type { SessionRecord } from "../src/types.js"; import { @@ -115,6 +120,21 @@ test("parseTtlSeconds rejects negative values", () => { assert.throws(() => parseTtlSeconds("-1"), InvalidArgumentError); }); +test("parseAllowedTools parses empty and comma-separated values", () => { + assert.deepEqual(parseAllowedTools(""), []); + assert.deepEqual(parseAllowedTools("Read,Grep, Glob"), ["Read", "Grep", "Glob"]); +}); + +test("parseAllowedTools rejects empty entries", () => { + assert.throws(() => parseAllowedTools("Read,,Grep"), InvalidArgumentError); +}); + +test("parseMaxTurns accepts positive integers and rejects invalid values", () => { + assert.equal(parseMaxTurns("3"), 3); + assert.throws(() => parseMaxTurns("0"), InvalidArgumentError); + assert.throws(() => parseMaxTurns("1.5"), InvalidArgumentError); +}); + test("formatPromptSessionBannerLine prints single-line prompt banner for matching cwd", () => { const record = makeSessionRecord({ acpxRecordId: "abc123", @@ -179,6 +199,16 @@ test("CLI resolves unknown subcommand names as raw agent commands", async () => }); }); +test("global passthrough flags are present in help output", async () => { + await withTempHome(async (homeDir) => { + const result = await runCli(["--help"], homeDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /--model /); + assert.match(result.stdout, /--allowed-tools /); + assert.match(result.stdout, /--max-turns /); + }); +}); + test("sessions new command is present in help output", async () => { await withTempHome(async (homeDir) => { const result = await runCli(["sessions", "--help"], homeDir); diff --git a/test/client.test.ts b/test/client.test.ts index 5a58a4f2..20c23076 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -216,6 +216,40 @@ test("AcpClient handlePermissionRequest records approved decisions", async () => }); }); +test("AcpClient createSession forwards claudeCode options in _meta", async () => { + const client = makeClient({ + sessionOptions: { + model: "sonnet", + allowedTools: ["Read", "Grep"], + maxTurns: 12, + }, + }); + + let capturedParams: Record | undefined; + asInternals(client).connection = { + newSession: async (params: Record) => { + capturedParams = params; + return { sessionId: "session-123" }; + }, + }; + + const result = await client.createSession("/tmp/acpx-client-meta"); + assert.equal(result.sessionId, "session-123"); + assert.deepEqual(capturedParams, { + cwd: "/tmp/acpx-client-meta", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "sonnet", + allowedTools: ["Read", "Grep"], + maxTurns: 12, + }, + }, + }, + }); +}); + test("AcpClient session update handling drains queued callbacks and swallows handler failures", async () => { const notifications: string[] = []; const client = makeClient({ diff --git a/test/integration.test.ts b/test/integration.test.ts index 9b4a959a..3fe97a60 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -45,6 +45,49 @@ test("integration: exec echo baseline", async () => { }); }); +test("integration: exec forwards model, allowed-tools, and max-turns in session/new _meta", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const result = await runCli( + [ + ...baseAgentArgs(cwd), + "--format", + "json", + "--model", + "sonnet", + "--allowed-tools", + "Read,Grep", + "--max-turns", + "7", + "exec", + "echo hello", + ], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const createRequest = payloads.find((payload) => payload.method === "session/new") as + | { params?: { _meta?: unknown } } + | undefined; + assert(createRequest, result.stdout); + assert.deepEqual(createRequest.params?._meta, { + claudeCode: { + options: { + model: "sonnet", + allowedTools: ["Read", "Grep"], + maxTurns: 7, + }, + }, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: perf metrics capture writes ndjson records for CLI runs", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));