diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 508325e7d41..8d3de8e5eb6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -20,6 +20,7 @@ import { ModelUsage, NonNullableUsage, } from "@anthropic-ai/claude-agent-sdk"; +import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, @@ -2742,6 +2743,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; + const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -2781,6 +2783,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; const queryRuntime = yield* Effect.try({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index dcf3fa189ef..d8a992f0ec3 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], + launchArgs: "", }); assert.deepEqual(next.textGenerationModelSelection, { provider: "codex", @@ -167,6 +168,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/claude", customModels: [], + launchArgs: "", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 92ba61aa5f4..6449a71587c 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -98,7 +98,7 @@ function createBaseServerConfig(): ServerConfig { textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, - claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, }, }, }; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1d297add3c5..f76c69581d8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -511,7 +511,8 @@ export function GeneralSettingsPanel() { claudeAgent: Boolean( settings.providers.claudeAgent.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || - settings.providers.claudeAgent.customModels.length > 0, + settings.providers.claudeAgent.customModels.length > 0 || + settings.providers.claudeAgent.launchArgs !== "", ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -1278,6 +1279,37 @@ export function GeneralSettingsPanel() { ) : null} + {providerCard.provider === "claudeAgent" ? ( +
+ +
+ ) : null} +
Models
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fe432e098b0..375f4ed27e2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -70,6 +70,7 @@ export const ClaudeSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), binaryPath: makeBinaryPathSetting("claude"), customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -164,6 +165,7 @@ const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(Schema.String), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + launchArgs: Schema.optionalKey(Schema.String), }); export const ServerSettingsPatch = Schema.Struct({ diff --git a/packages/shared/package.json b/packages/shared/package.json index fe11f2e315e..3789e3cfafb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -60,6 +60,10 @@ "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" }, + "./cliArgs": { + "types": "./src/cliArgs.ts", + "import": "./src/cliArgs.ts" + }, "./path": { "types": "./src/path.ts", "import": "./src/path.ts" diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts new file mode 100644 index 00000000000..02c0b48805b --- /dev/null +++ b/packages/shared/src/cliArgs.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { parseCliArgs } from "./cliArgs"; + +describe("parseCliArgs", () => { + it("returns empty result for empty string", () => { + expect(parseCliArgs("")).toEqual({ flags: {}, positionals: [] }); + }); + + it("returns empty result for whitespace-only string", () => { + expect(parseCliArgs(" ")).toEqual({ flags: {}, positionals: [] }); + }); + + it("returns empty result for empty array", () => { + expect(parseCliArgs([])).toEqual({ flags: {}, positionals: [] }); + }); + + it("parses --chrome boolean flag", () => { + expect(parseCliArgs("--chrome")).toEqual({ + flags: { chrome: null }, + positionals: [], + }); + }); + + it("parses --chrome with --verbose", () => { + expect(parseCliArgs("--chrome --verbose")).toEqual({ + flags: { chrome: null, verbose: null }, + positionals: [], + }); + }); + + it("parses --effort with a value", () => { + expect(parseCliArgs("--effort high")).toEqual({ + flags: { effort: "high" }, + positionals: [], + }); + }); + + it("parses --chrome --effort high --debug", () => { + expect(parseCliArgs("--chrome --effort high --debug")).toEqual({ + flags: { chrome: null, effort: "high", debug: null }, + positionals: [], + }); + }); + + it("parses --model with full model name", () => { + expect(parseCliArgs("--model claude-sonnet-4-6")).toEqual({ + flags: { model: "claude-sonnet-4-6" }, + positionals: [], + }); + }); + + it("parses --append-system-prompt with value and --chrome", () => { + expect(parseCliArgs("--append-system-prompt always-think-step-by-step --chrome")).toEqual({ + flags: { "append-system-prompt": "always-think-step-by-step", chrome: null }, + positionals: [], + }); + }); + + it("parses --max-budget-usd with numeric value", () => { + expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({ + flags: { chrome: null, "max-budget-usd": "5.00" }, + positionals: [], + }); + }); + + it("parses --effort=high syntax", () => { + expect(parseCliArgs("--effort=high")).toEqual({ + flags: { effort: "high" }, + positionals: [], + }); + }); + + it("parses --key=value mixed with boolean flags", () => { + expect(parseCliArgs("--chrome --model=claude-sonnet-4-6 --debug")).toEqual({ + flags: { chrome: null, model: "claude-sonnet-4-6", debug: null }, + positionals: [], + }); + }); + + it("collects positional arguments", () => { + expect(parseCliArgs("1.2.3")).toEqual({ + flags: {}, + positionals: ["1.2.3"], + }); + }); + + it("collects positionals mixed with flags (argv array)", () => { + expect(parseCliArgs(["1.2.3", "--root", "/path", "--github-output"])).toEqual({ + flags: { root: "/path", "github-output": null }, + positionals: ["1.2.3"], + }); + }); + + it("handles extra whitespace between tokens", () => { + expect(parseCliArgs(" --chrome --verbose ")).toEqual({ + flags: { chrome: null, verbose: null }, + positionals: [], + }); + }); + + it("ignores bare -- with no flag name", () => { + expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); + }); + + it("boolean flag does not consume next token as value", () => { + expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( + { + flags: { "github-output": null }, + positionals: ["1.2.3"], + }, + ); + }); + + it("non-boolean flag still consumes next token", () => { + expect(parseCliArgs(["--root", "/path", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( + { + flags: { root: "/path" }, + positionals: ["1.2.3"], + }, + ); + }); + + it("mixes boolean and value flags with positionals", () => { + expect( + parseCliArgs(["--github-output", "--root", "/path", "1.2.3"], { + booleanFlags: ["github-output"], + }), + ).toEqual({ + flags: { "github-output": null, root: "/path" }, + positionals: ["1.2.3"], + }); + }); +}); diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts new file mode 100644 index 00000000000..20920093302 --- /dev/null +++ b/packages/shared/src/cliArgs.ts @@ -0,0 +1,76 @@ +export interface ParsedCliArgs { + readonly flags: Record; + readonly positionals: string[]; +} + +export interface ParseCliArgsOptions { + readonly booleanFlags?: readonly string[]; +} + +/** + * Parse CLI-style arguments into flags and positionals. + * + * Accepts a string (split by whitespace) or a pre-split argv array. + * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax. + * + * parseCliArgs("") + * → { flags: {}, positionals: [] } + * + * parseCliArgs("--chrome") + * → { flags: { chrome: null }, positionals: [] } + * + * parseCliArgs("--chrome --effort high") + * → { flags: { chrome: null, effort: "high" }, positionals: [] } + * + * parseCliArgs("--effort=high") + * → { flags: { effort: "high" }, positionals: [] } + * + * parseCliArgs(["1.2.3", "--root", "/path", "--github-output"], { booleanFlags: ["github-output"] }) + * → { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] } + */ +export function parseCliArgs( + args: string | readonly string[], + options?: ParseCliArgsOptions, +): ParsedCliArgs { + const tokens = + typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args); + const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined; + + const flags: Record = {}; + const positionals: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + + if (token.startsWith("--")) { + const rest = token.slice(2); + if (!rest) continue; + + // Handle --key=value syntax + const eqIndex = rest.indexOf("="); + if (eqIndex !== -1) { + flags[rest.slice(0, eqIndex)] = rest.slice(eqIndex + 1); + continue; + } + + // Known boolean flag — never consumes next token + if (booleanSet?.has(rest)) { + flags[rest] = null; + continue; + } + + // Handle --key value or --flag (boolean) + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + flags[rest] = next; + i++; + } else { + flags[rest] = null; + } + } else { + positionals.push(token); + } + } + + return { flags, positionals }; +} diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts new file mode 100644 index 00000000000..9e31c7675b3 --- /dev/null +++ b/scripts/update-release-package-versions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { parseArgs } from "./update-release-package-versions.ts"; + +describe("parseArgs", () => { + it("parses version only", () => { + expect(parseArgs(["1.2.3"])).toEqual({ + version: "1.2.3", + rootDir: undefined, + writeGithubOutput: false, + }); + }); + + it("parses version with --root", () => { + expect(parseArgs(["1.2.3", "--root", "/path"])).toEqual({ + version: "1.2.3", + rootDir: "/path", + writeGithubOutput: false, + }); + }); + + it("parses version with --github-output", () => { + expect(parseArgs(["1.2.3", "--github-output"])).toEqual({ + version: "1.2.3", + rootDir: undefined, + writeGithubOutput: true, + }); + }); + + it("parses version with --root and --github-output", () => { + expect(parseArgs(["1.2.3", "--root", "/path", "--github-output"])).toEqual({ + version: "1.2.3", + rootDir: "/path", + writeGithubOutput: true, + }); + }); + + it("accepts flags before the version positional", () => { + expect(parseArgs(["--github-output", "--root", "/path", "1.2.3"])).toEqual({ + version: "1.2.3", + rootDir: "/path", + writeGithubOutput: true, + }); + }); + + it("throws on missing version", () => { + expect(() => parseArgs([])).toThrow("Usage:"); + }); + + it("throws on duplicate version", () => { + expect(() => parseArgs(["1.2.3", "2.0.0"])).toThrow( + "Only one release version can be provided.", + ); + }); + + it("throws on unknown flag", () => { + expect(() => parseArgs(["1.2.3", "--unknown"])).toThrow("Unknown argument: --unknown"); + }); + + it("throws on --root without value", () => { + expect(() => parseArgs(["1.2.3", "--root"])).toThrow("Missing value for --root."); + }); + + it("does not consume version as --github-output value", () => { + expect(parseArgs(["--github-output", "1.2.3"])).toEqual({ + version: "1.2.3", + rootDir: undefined, + writeGithubOutput: true, + }); + }); +}); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e8e8..cefeef33ea2 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -2,6 +2,8 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseCliArgs } from "@t3tools/shared/cliArgs"; + export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", @@ -40,52 +42,37 @@ export function updateReleasePackageVersions( return { changed }; } -function parseArgs(argv: ReadonlyArray): { +export function parseArgs(argv: ReadonlyArray): { version: string; rootDir: string | undefined; writeGithubOutput: boolean; } { - let version: string | undefined; - let rootDir: string | undefined; - let writeGithubOutput = false; - - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === undefined) { - continue; - } - - if (argument === "--github-output") { - writeGithubOutput = true; - continue; - } + const { flags, positionals } = parseCliArgs(argv, { booleanFlags: ["github-output"] }); - if (argument === "--root") { - rootDir = argv[index + 1]; - if (!rootDir) { - throw new Error("Missing value for --root."); - } - index += 1; - continue; - } + const unknownFlags = Object.keys(flags).filter((k) => k !== "github-output" && k !== "root"); + if (unknownFlags.length > 0) { + throw new Error(`Unknown argument: --${unknownFlags[0]}`); + } - if (argument.startsWith("--")) { - throw new Error(`Unknown argument: ${argument}`); - } + if ("root" in flags && flags.root === null) { + throw new Error("Missing value for --root."); + } - if (version !== undefined) { - throw new Error("Only one release version can be provided."); - } - version = argument; + if (positionals.length > 1) { + throw new Error("Only one release version can be provided."); } - if (!version) { + if (positionals.length !== 1 || !positionals[0]) { throw new Error( "Usage: node scripts/update-release-package-versions.ts [--root ] [--github-output]", ); } - return { version, rootDir, writeGithubOutput }; + return { + version: positionals[0], + rootDir: flags.root ?? undefined, + writeGithubOutput: "github-output" in flags, + }; } const isMain =