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 =