Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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 @@ -2742,6 +2743,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 @@ -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({
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
2 changes: 1 addition & 1 deletion apps/web/src/components/KeybindingsToast.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" },
},
},
};
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -1278,6 +1279,37 @@ 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 @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
134 changes: 134 additions & 0 deletions packages/shared/src/cliArgs.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
});
});
});
76 changes: 76 additions & 0 deletions packages/shared/src/cliArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export interface ParsedCliArgs {
readonly flags: Record<string, string | null>;
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);
Comment thread
akarabach marked this conversation as resolved.
const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined;

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;
}

// 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 };
}
Loading
Loading