From 9a77e5584244675183b64fdf93e6ff50815162cd Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:45:00 +0300 Subject: [PATCH 1/6] feat: add Enable Chrome setting for Claude provider Claude Code supports a --chrome flag to launch with Chrome browser integration, but the SDK query path used by T3 Code never passed it. Add an `enableChrome` boolean to Claude provider settings (default off) and forward it via the SDK's `extraArgs` option when enabled. --- .../src/provider/Layers/ClaudeAdapter.ts | 2 ++ .../components/settings/SettingsPanels.tsx | 33 ++++++++++++++++++- packages/contracts/src/settings.ts | 2 ++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1a0657c499d..b7e09f1b595 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2680,6 +2680,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; + const enableChrome = claudeSettings.enableChrome; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -2719,6 +2720,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(enableChrome ? { extraArgs: { chrome: null } } : {}), }; const queryRuntime = yield* Effect.try({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f26436..ec5f3b34e57 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -440,7 +440,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.enableChrome, ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -1179,6 +1180,36 @@ export function GeneralSettingsPanel() { ) : null} + {providerCard.provider === "claudeAgent" ? ( +
+
+
+ + Enable Chrome + + + Launch Claude with Chrome browser integration (--chrome). + +
+ + updateSettings({ + providers: { + ...settings.providers, + claudeAgent: { + ...settings.providers.claudeAgent, + enableChrome: Boolean(checked), + }, + }, + }) + } + aria-label="Enable Chrome browser integration" + /> +
+
+ ) : null} +
Models
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee568..499175fbd57 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([]))), + enableChrome: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -163,6 +164,7 @@ const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(Schema.String), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + enableChrome: Schema.optionalKey(Schema.Boolean), }); export const ServerSettingsPatch = Schema.Struct({ From 9873c016fb513b8004c3d4e6951a949637a6ec7e Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:09:34 +0300 Subject: [PATCH 2/6] feat: launch args instead of specific chrome flag - fix test - add tests for parser --- .../src/provider/Layers/ClaudeAdapter.test.ts | 70 ++++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 35 +++++++++- apps/server/src/serverSettings.test.ts | 2 + .../components/settings/SettingsPanels.tsx | 36 +++++----- packages/contracts/src/settings.ts | 4 +- 5 files changed, 126 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 8b1f1389c41..2a93aace199 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -25,7 +25,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { makeClaudeAdapterLive, parseLaunchArgs, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; class FakeClaudeQuery implements AsyncIterable { private readonly queue: Array = []; @@ -3219,3 +3219,71 @@ describe("ClaudeAdapterLive", () => { ); }); }); + +describe("parseLaunchArgs", () => { + it("returns empty object for empty string", () => { + assert.deepEqual(parseLaunchArgs(""), {}); + }); + + it("returns empty object for whitespace-only string", () => { + assert.deepEqual(parseLaunchArgs(" "), {}); + }); + + it("parses --chrome boolean flag", () => { + assert.deepEqual(parseLaunchArgs("--chrome"), { chrome: null }); + }); + + it("parses --chrome with --verbose", () => { + assert.deepEqual(parseLaunchArgs("--chrome --verbose"), { + chrome: null, + verbose: null, + }); + }); + + it("parses --effort with a value", () => { + assert.deepEqual(parseLaunchArgs("--effort high"), { effort: "high" }); + }); + + it("parses --chrome --effort high --debug", () => { + assert.deepEqual(parseLaunchArgs("--chrome --effort high --debug"), { + chrome: null, + effort: "high", + debug: null, + }); + }); + + it("parses --model with full model name", () => { + assert.deepEqual(parseLaunchArgs("--model claude-sonnet-4-6"), { + model: "claude-sonnet-4-6", + }); + }); + + it("parses --append-system-prompt with value and --chrome", () => { + assert.deepEqual(parseLaunchArgs("--append-system-prompt always-think-step-by-step --chrome"), { + "append-system-prompt": "always-think-step-by-step", + chrome: null, + }); + }); + + it("parses --max-budget-usd with numeric value", () => { + assert.deepEqual(parseLaunchArgs("--chrome --max-budget-usd 5.00"), { + chrome: null, + "max-budget-usd": "5.00", + }); + }); + + it("handles extra whitespace between tokens", () => { + assert.deepEqual(parseLaunchArgs(" --chrome --verbose "), { + chrome: null, + verbose: null, + }); + }); + + it("ignores bare -- with no flag name", () => { + assert.deepEqual(parseLaunchArgs("--"), {}); + }); + + it("ignores tokens that don't start with --", () => { + assert.deepEqual(parseLaunchArgs("chrome"), {}); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b7e09f1b595..d286801ed88 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -506,6 +506,35 @@ const CLAUDE_SETTING_SOURCES = [ "local", ] as const satisfies ReadonlyArray; +/** + * Parse a CLI-style launch args string into the SDK's `extraArgs` format. + * + * Boolean flags (no value) become `null`, flags with a value become strings: + * "" → {} + * "--chrome" → { chrome: null } + * "--chrome --debug" → { chrome: null, debug: null } + * "--chrome --max-turns 5" → { chrome: null, "max-turns": "5" } + */ +export function parseLaunchArgs(args: string): Record { + const result: Record = {}; + const tokens = args.trim().split(/\s+/).filter(Boolean); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.startsWith("--")) { + const key = token.slice(2); + if (!key) continue; + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + result[key] = next; + i++; + } else { + result[key] = null; + } + } + } + return result; +} + function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; @@ -2680,7 +2709,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; - const enableChrome = claudeSettings.enableChrome; + const launchArgs = claudeSettings.launchArgs; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -2720,7 +2749,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), - ...(enableChrome ? { extraArgs: { chrome: null } } : {}), + ...(Object.keys(parseLaunchArgs(launchArgs)).length > 0 + ? { extraArgs: parseLaunchArgs(launchArgs) } + : {}), }; const queryRuntime = yield* Effect.try({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 289bc689617..d3d92e42126 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/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ec5f3b34e57..fb234805c3a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -441,7 +441,7 @@ export function GeneralSettingsPanel() { settings.providers.claudeAgent.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || settings.providers.claudeAgent.customModels.length > 0 || - settings.providers.claudeAgent.enableChrome, + settings.providers.claudeAgent.launchArgs !== "", ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -1182,31 +1182,35 @@ export function GeneralSettingsPanel() { {providerCard.provider === "claudeAgent" ? (
-
-
- - Enable Chrome - - - Launch Claude with Chrome browser integration (--chrome). - -
- +
+ + Additional CLI arguments passed to Claude Code on session start. + +
) : null} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 499175fbd57..a334553006c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -70,7 +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([]))), - enableChrome: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + launchArgs: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -164,7 +164,7 @@ const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(Schema.String), customModels: Schema.optionalKey(Schema.Array(Schema.String)), - enableChrome: Schema.optionalKey(Schema.Boolean), + launchArgs: Schema.optionalKey(Schema.String), }); export const ServerSettingsPatch = Schema.Struct({ From cfefd477ca6e8f84fd455028555c8a0548978942 Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:30:13 +0300 Subject: [PATCH 3/6] feat: use normal string with spaces --- packages/contracts/src/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a334553006c..dcb13127b1b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -70,7 +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: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ClaudeSettings = typeof ClaudeSettings.Type; From 173029192f2da8c19f27ea40505fd4911452f8d8 Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:40:25 +0300 Subject: [PATCH 4/6] feat: fix parse args twice --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d286801ed88..d65c7f8d8c0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2709,7 +2709,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; - const launchArgs = claudeSettings.launchArgs; + const extraArgs = parseLaunchArgs(claudeSettings.launchArgs); const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -2749,9 +2749,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), - ...(Object.keys(parseLaunchArgs(launchArgs)).length > 0 - ? { extraArgs: parseLaunchArgs(launchArgs) } - : {}), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; const queryRuntime = yield* Effect.try({ From 55333640826d3e831581082742d18800210baeab Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:35:23 +0300 Subject: [PATCH 5/6] feat: common parser of cli args, unit tests --- .../src/provider/Layers/ClaudeAdapter.test.ts | 69 +----------- .../src/provider/Layers/ClaudeAdapter.ts | 32 +----- packages/shared/package.json | 4 + packages/shared/src/cliArgs.test.ts | 105 ++++++++++++++++++ packages/shared/src/cliArgs.ts | 62 +++++++++++ .../update-release-package-versions.test.ts | 63 +++++++++++ scripts/update-release-package-versions.ts | 51 ++++----- 7 files changed, 256 insertions(+), 130 deletions(-) create mode 100644 packages/shared/src/cliArgs.test.ts create mode 100644 packages/shared/src/cliArgs.ts create mode 100644 scripts/update-release-package-versions.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2a93aace199..44b7d090d4e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -25,7 +25,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { makeClaudeAdapterLive, parseLaunchArgs, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; class FakeClaudeQuery implements AsyncIterable { private readonly queue: Array = []; @@ -3220,70 +3220,3 @@ describe("ClaudeAdapterLive", () => { }); }); -describe("parseLaunchArgs", () => { - it("returns empty object for empty string", () => { - assert.deepEqual(parseLaunchArgs(""), {}); - }); - - it("returns empty object for whitespace-only string", () => { - assert.deepEqual(parseLaunchArgs(" "), {}); - }); - - it("parses --chrome boolean flag", () => { - assert.deepEqual(parseLaunchArgs("--chrome"), { chrome: null }); - }); - - it("parses --chrome with --verbose", () => { - assert.deepEqual(parseLaunchArgs("--chrome --verbose"), { - chrome: null, - verbose: null, - }); - }); - - it("parses --effort with a value", () => { - assert.deepEqual(parseLaunchArgs("--effort high"), { effort: "high" }); - }); - - it("parses --chrome --effort high --debug", () => { - assert.deepEqual(parseLaunchArgs("--chrome --effort high --debug"), { - chrome: null, - effort: "high", - debug: null, - }); - }); - - it("parses --model with full model name", () => { - assert.deepEqual(parseLaunchArgs("--model claude-sonnet-4-6"), { - model: "claude-sonnet-4-6", - }); - }); - - it("parses --append-system-prompt with value and --chrome", () => { - assert.deepEqual(parseLaunchArgs("--append-system-prompt always-think-step-by-step --chrome"), { - "append-system-prompt": "always-think-step-by-step", - chrome: null, - }); - }); - - it("parses --max-budget-usd with numeric value", () => { - assert.deepEqual(parseLaunchArgs("--chrome --max-budget-usd 5.00"), { - chrome: null, - "max-budget-usd": "5.00", - }); - }); - - it("handles extra whitespace between tokens", () => { - assert.deepEqual(parseLaunchArgs(" --chrome --verbose "), { - chrome: null, - verbose: null, - }); - }); - - it("ignores bare -- with no flag name", () => { - assert.deepEqual(parseLaunchArgs("--"), {}); - }); - - it("ignores tokens that don't start with --", () => { - assert.deepEqual(parseLaunchArgs("chrome"), {}); - }); -}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d65c7f8d8c0..5b98eac3432 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, @@ -506,35 +507,6 @@ const CLAUDE_SETTING_SOURCES = [ "local", ] as const satisfies ReadonlyArray; -/** - * Parse a CLI-style launch args string into the SDK's `extraArgs` format. - * - * Boolean flags (no value) become `null`, flags with a value become strings: - * "" → {} - * "--chrome" → { chrome: null } - * "--chrome --debug" → { chrome: null, debug: null } - * "--chrome --max-turns 5" → { chrome: null, "max-turns": "5" } - */ -export function parseLaunchArgs(args: string): Record { - const result: Record = {}; - const tokens = args.trim().split(/\s+/).filter(Boolean); - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]!; - if (token.startsWith("--")) { - const key = token.slice(2); - if (!key) continue; - const next = tokens[i + 1]; - if (next !== undefined && !next.startsWith("--")) { - result[key] = next; - i++; - } else { - result[key] = null; - } - } - } - return result; -} - function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; @@ -2709,7 +2681,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; - const extraArgs = parseLaunchArgs(claudeSettings.launchArgs); + const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); diff --git a/packages/shared/package.json b/packages/shared/package.json index ed65cbeaf3c..2fcc327e6b8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,10 @@ "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" + }, + "./cliArgs": { + "types": "./src/cliArgs.ts", + "import": "./src/cliArgs.ts" } }, "scripts": { diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts new file mode 100644 index 00000000000..2afc5856a95 --- /dev/null +++ b/packages/shared/src/cliArgs.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { parseCliArgs } from "./cliArgs.ts"; + +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: [] }); + }); +}); diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts new file mode 100644 index 00000000000..6f75387363d --- /dev/null +++ b/packages/shared/src/cliArgs.ts @@ -0,0 +1,62 @@ +export interface ParsedCliArgs { + readonly flags: Record; + readonly positionals: 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"]) + * → { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] } + */ +export function parseCliArgs(args: string | readonly string[]): ParsedCliArgs { + const tokens = + typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args); + + 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; + } + + // 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..41056438c85 --- /dev/null +++ b/scripts/update-release-package-versions.test.ts @@ -0,0 +1,63 @@ +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."); + }); +}); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e8e8..e56babef4e0 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); - 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 = From 459c1308a990bc3ec085a1891c05b9cc6f39615c Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:38:09 +0300 Subject: [PATCH 6/6] feat: fixes cursor comment add boolean flags parameter --- packages/shared/src/cliArgs.test.ts | 31 ++++++++++++++++++- packages/shared/src/cliArgs.ts | 15 +++++++-- .../update-release-package-versions.test.ts | 8 +++++ scripts/update-release-package-versions.ts | 2 +- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index 2afc5856a95..02c0b48805b 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseCliArgs } from "./cliArgs.ts"; +import { parseCliArgs } from "./cliArgs"; describe("parseCliArgs", () => { it("returns empty result for empty string", () => { @@ -102,4 +102,33 @@ describe("parseCliArgs", () => { 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 index 6f75387363d..0cb0583ce51 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -3,6 +3,10 @@ export interface ParsedCliArgs { readonly positionals: string[]; } +export interface ParseCliArgsOptions { + readonly booleanFlags?: readonly string[]; +} + /** * Parse CLI-style arguments into flags and positionals. * @@ -21,12 +25,13 @@ export interface ParsedCliArgs { * parseCliArgs("--effort=high") * → { flags: { effort: "high" }, positionals: [] } * - * parseCliArgs(["1.2.3", "--root", "/path", "--github-output"]) + * 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[]): ParsedCliArgs { +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[] = []; @@ -45,6 +50,12 @@ export function parseCliArgs(args: string | readonly string[]): ParsedCliArgs { 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("--")) { diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 41056438c85..9e31c7675b3 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -60,4 +60,12 @@ describe("parseArgs", () => { 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 e56babef4e0..cefeef33ea2 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -47,7 +47,7 @@ export function parseArgs(argv: ReadonlyArray): { rootDir: string | undefined; writeGithubOutput: boolean; } { - const { flags, positionals } = parseCliArgs(argv); + const { flags, positionals } = parseCliArgs(argv, { booleanFlags: ["github-output"] }); const unknownFlags = Object.keys(flags).filter((k) => k !== "github-output" && k !== "root"); if (unknownFlags.length > 0) {