Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3219,3 +3219,4 @@ describe("ClaudeAdapterLive", () => {
);
});
});

3 changes: 3 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ModelUsage,
NonNullableUsage,
} from "@anthropic-ai/claude-agent-sdk";
import { parseCliArgs } from "@t3tools/shared/cliArgs";
import {
ApprovalRequestId,
type CanonicalItemType,
Expand Down Expand Up @@ -2680,6 +2681,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
),
);
const claudeBinaryPath = claudeSettings.binaryPath;
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if I enter some invalid value in the input box there's no indication of that and they'll be silently ignored?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Claude won't be able to start.

Screenshot 2026-04-14 at 01 01 31

Copy link
Copy Markdown
Contributor Author

@akarabach akarabach Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered a few approaches for validating the launch args input and decided to keep it as a plain text field without validation. Here's the reasoning:

  1. Runtime extraction - run claude --help when the user opens settings, parse the output to get valid flags, validate against them. Problems: --help output is unstructured text (no --json option), parsing is fragile across CLI versions, and we'd need to filter out flags the SDK already handles (--model, --effort, --resume, etc.) to avoid conflicts.

  2. Hardcoded flag list - maintain a static list of valid Claude CLI flags. Problems: Claude CLI updates frequently, the list would go stale fast, and false negatives on new valid flags would be worse than no validation.

const modelSelection =
input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
const caps = getClaudeModelCapabilities(modelSelection?.model);
Expand Down Expand Up @@ -2719,6 +2721,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({
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -167,6 +168,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
enabled: true,
binaryPath: "/opt/homebrew/bin/claude",
customModels: [],
launchArgs: "",
});
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
Expand Down
37 changes: 36 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.launchArgs !== "",
),
});
const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
Expand Down Expand Up @@ -1179,6 +1180,40 @@ export function GeneralSettingsPanel() {
</div>
) : null}

{providerCard.provider === "claudeAgent" ? (
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
<label
htmlFor="provider-install-claudeAgent-launch-args"
className="block"
>
<span className="text-xs font-medium text-foreground">
Launch arguments
</span>
<Input
id="provider-install-claudeAgent-launch-args"
className="mt-1.5"
value={settings.providers.claudeAgent.launchArgs}
onChange={(event) =>
updateSettings({
providers: {
...settings.providers,
claudeAgent: {
...settings.providers.claudeAgent,
launchArgs: event.target.value,
},
},
})
}
placeholder="e.g. --chrome"
spellCheck={false}
/>
<span className="mt-1 block text-xs text-muted-foreground">
Additional CLI arguments passed to Claude Code on session start.
</span>
</label>
</div>
) : null}

<div className="border-t border-border/60 px-4 py-3 sm:px-5">
<div className="text-xs font-medium text-foreground">Models</div>
<div className="mt-1 text-xs text-muted-foreground">
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)),
launchArgs: Schema.optionalKey(Schema.String),
});

export const ServerSettingsPatch = Schema.Struct({
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
"./qrCode": {
"types": "./src/qrCode.ts",
"import": "./src/qrCode.ts"
},
"./cliArgs": {
"types": "./src/cliArgs.ts",
"import": "./src/cliArgs.ts"
}
},
"scripts": {
Expand Down
105 changes: 105 additions & 0 deletions packages/shared/src/cliArgs.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
62 changes: 62 additions & 0 deletions packages/shared/src/cliArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export interface ParsedCliArgs {
readonly flags: Record<string, string | null>;
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);
Comment thread
akarabach marked this conversation as resolved.

const flags: Record<string, string | null> = {};
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 };
}
63 changes: 63 additions & 0 deletions scripts/update-release-package-versions.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
Loading
Loading