Skip to content

Commit 5e1dd56

Browse files
feat: add Launch Args setting for Claude provider (#1971)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 68061af commit 5e1dd56

File tree

10 files changed

+345
-34
lines changed

10 files changed

+345
-34
lines changed

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
ModelUsage,
2121
NonNullableUsage,
2222
} from "@anthropic-ai/claude-agent-sdk";
23+
import { parseCliArgs } from "@t3tools/shared/cliArgs";
2324
import {
2425
ApprovalRequestId,
2526
type CanonicalItemType,
@@ -2742,6 +2743,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27422743
),
27432744
);
27442745
const claudeBinaryPath = claudeSettings.binaryPath;
2746+
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
27452747
const modelSelection =
27462748
input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
27472749
const caps = getClaudeModelCapabilities(modelSelection?.model);
@@ -2781,6 +2783,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27812783
canUseTool,
27822784
env: process.env,
27832785
...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
2786+
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
27842787
};
27852788

27862789
const queryRuntime = yield* Effect.try({

apps/server/src/serverSettings.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
9292
enabled: true,
9393
binaryPath: "/usr/local/bin/claude",
9494
customModels: ["claude-custom"],
95+
launchArgs: "",
9596
});
9697
assert.deepEqual(next.textGenerationModelSelection, {
9798
provider: "codex",
@@ -167,6 +168,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
167168
enabled: true,
168169
binaryPath: "/opt/homebrew/bin/claude",
169170
customModels: [],
171+
launchArgs: "",
170172
});
171173
}).pipe(Effect.provide(makeServerSettingsLayer())),
172174
);

apps/web/src/components/KeybindingsToast.browser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ function createBaseServerConfig(): ServerConfig {
9898
textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" },
9999
providers: {
100100
codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
101-
claudeAgent: { enabled: true, binaryPath: "", customModels: [] },
101+
claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },
102102
},
103103
},
104104
};

apps/web/src/components/settings/SettingsPanels.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,8 @@ export function GeneralSettingsPanel() {
511511
claudeAgent: Boolean(
512512
settings.providers.claudeAgent.binaryPath !==
513513
DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ||
514-
settings.providers.claudeAgent.customModels.length > 0,
514+
settings.providers.claudeAgent.customModels.length > 0 ||
515+
settings.providers.claudeAgent.launchArgs !== "",
515516
),
516517
});
517518
const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
@@ -1278,6 +1279,37 @@ export function GeneralSettingsPanel() {
12781279
</div>
12791280
) : null}
12801281

1282+
{providerCard.provider === "claudeAgent" ? (
1283+
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
1284+
<label htmlFor="provider-install-claudeAgent-launch-args" className="block">
1285+
<span className="text-xs font-medium text-foreground">
1286+
Launch arguments
1287+
</span>
1288+
<Input
1289+
id="provider-install-claudeAgent-launch-args"
1290+
className="mt-1.5"
1291+
value={settings.providers.claudeAgent.launchArgs}
1292+
onChange={(event) =>
1293+
updateSettings({
1294+
providers: {
1295+
...settings.providers,
1296+
claudeAgent: {
1297+
...settings.providers.claudeAgent,
1298+
launchArgs: event.target.value,
1299+
},
1300+
},
1301+
})
1302+
}
1303+
placeholder="e.g. --chrome"
1304+
spellCheck={false}
1305+
/>
1306+
<span className="mt-1 block text-xs text-muted-foreground">
1307+
Additional CLI arguments passed to Claude Code on session start.
1308+
</span>
1309+
</label>
1310+
</div>
1311+
) : null}
1312+
12811313
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
12821314
<div className="text-xs font-medium text-foreground">Models</div>
12831315
<div className="mt-1 text-xs text-muted-foreground">

packages/contracts/src/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const ClaudeSettings = Schema.Struct({
7070
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
7171
binaryPath: makeBinaryPathSetting("claude"),
7272
customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
73+
launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
7374
});
7475
export type ClaudeSettings = typeof ClaudeSettings.Type;
7576

@@ -164,6 +165,7 @@ const ClaudeSettingsPatch = Schema.Struct({
164165
enabled: Schema.optionalKey(Schema.Boolean),
165166
binaryPath: Schema.optionalKey(Schema.String),
166167
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
168+
launchArgs: Schema.optionalKey(Schema.String),
167169
});
168170

169171
export const ServerSettingsPatch = Schema.Struct({

packages/shared/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
"types": "./src/qrCode.ts",
6161
"import": "./src/qrCode.ts"
6262
},
63+
"./cliArgs": {
64+
"types": "./src/cliArgs.ts",
65+
"import": "./src/cliArgs.ts"
66+
},
6367
"./path": {
6468
"types": "./src/path.ts",
6569
"import": "./src/path.ts"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { parseCliArgs } from "./cliArgs";
4+
5+
describe("parseCliArgs", () => {
6+
it("returns empty result for empty string", () => {
7+
expect(parseCliArgs("")).toEqual({ flags: {}, positionals: [] });
8+
});
9+
10+
it("returns empty result for whitespace-only string", () => {
11+
expect(parseCliArgs(" ")).toEqual({ flags: {}, positionals: [] });
12+
});
13+
14+
it("returns empty result for empty array", () => {
15+
expect(parseCliArgs([])).toEqual({ flags: {}, positionals: [] });
16+
});
17+
18+
it("parses --chrome boolean flag", () => {
19+
expect(parseCliArgs("--chrome")).toEqual({
20+
flags: { chrome: null },
21+
positionals: [],
22+
});
23+
});
24+
25+
it("parses --chrome with --verbose", () => {
26+
expect(parseCliArgs("--chrome --verbose")).toEqual({
27+
flags: { chrome: null, verbose: null },
28+
positionals: [],
29+
});
30+
});
31+
32+
it("parses --effort with a value", () => {
33+
expect(parseCliArgs("--effort high")).toEqual({
34+
flags: { effort: "high" },
35+
positionals: [],
36+
});
37+
});
38+
39+
it("parses --chrome --effort high --debug", () => {
40+
expect(parseCliArgs("--chrome --effort high --debug")).toEqual({
41+
flags: { chrome: null, effort: "high", debug: null },
42+
positionals: [],
43+
});
44+
});
45+
46+
it("parses --model with full model name", () => {
47+
expect(parseCliArgs("--model claude-sonnet-4-6")).toEqual({
48+
flags: { model: "claude-sonnet-4-6" },
49+
positionals: [],
50+
});
51+
});
52+
53+
it("parses --append-system-prompt with value and --chrome", () => {
54+
expect(parseCliArgs("--append-system-prompt always-think-step-by-step --chrome")).toEqual({
55+
flags: { "append-system-prompt": "always-think-step-by-step", chrome: null },
56+
positionals: [],
57+
});
58+
});
59+
60+
it("parses --max-budget-usd with numeric value", () => {
61+
expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({
62+
flags: { chrome: null, "max-budget-usd": "5.00" },
63+
positionals: [],
64+
});
65+
});
66+
67+
it("parses --effort=high syntax", () => {
68+
expect(parseCliArgs("--effort=high")).toEqual({
69+
flags: { effort: "high" },
70+
positionals: [],
71+
});
72+
});
73+
74+
it("parses --key=value mixed with boolean flags", () => {
75+
expect(parseCliArgs("--chrome --model=claude-sonnet-4-6 --debug")).toEqual({
76+
flags: { chrome: null, model: "claude-sonnet-4-6", debug: null },
77+
positionals: [],
78+
});
79+
});
80+
81+
it("collects positional arguments", () => {
82+
expect(parseCliArgs("1.2.3")).toEqual({
83+
flags: {},
84+
positionals: ["1.2.3"],
85+
});
86+
});
87+
88+
it("collects positionals mixed with flags (argv array)", () => {
89+
expect(parseCliArgs(["1.2.3", "--root", "/path", "--github-output"])).toEqual({
90+
flags: { root: "/path", "github-output": null },
91+
positionals: ["1.2.3"],
92+
});
93+
});
94+
95+
it("handles extra whitespace between tokens", () => {
96+
expect(parseCliArgs(" --chrome --verbose ")).toEqual({
97+
flags: { chrome: null, verbose: null },
98+
positionals: [],
99+
});
100+
});
101+
102+
it("ignores bare -- with no flag name", () => {
103+
expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] });
104+
});
105+
106+
it("boolean flag does not consume next token as value", () => {
107+
expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual(
108+
{
109+
flags: { "github-output": null },
110+
positionals: ["1.2.3"],
111+
},
112+
);
113+
});
114+
115+
it("non-boolean flag still consumes next token", () => {
116+
expect(parseCliArgs(["--root", "/path", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual(
117+
{
118+
flags: { root: "/path" },
119+
positionals: ["1.2.3"],
120+
},
121+
);
122+
});
123+
124+
it("mixes boolean and value flags with positionals", () => {
125+
expect(
126+
parseCliArgs(["--github-output", "--root", "/path", "1.2.3"], {
127+
booleanFlags: ["github-output"],
128+
}),
129+
).toEqual({
130+
flags: { "github-output": null, root: "/path" },
131+
positionals: ["1.2.3"],
132+
});
133+
});
134+
});

packages/shared/src/cliArgs.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
export interface ParsedCliArgs {
2+
readonly flags: Record<string, string | null>;
3+
readonly positionals: string[];
4+
}
5+
6+
export interface ParseCliArgsOptions {
7+
readonly booleanFlags?: readonly string[];
8+
}
9+
10+
/**
11+
* Parse CLI-style arguments into flags and positionals.
12+
*
13+
* Accepts a string (split by whitespace) or a pre-split argv array.
14+
* Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax.
15+
*
16+
* parseCliArgs("")
17+
* → { flags: {}, positionals: [] }
18+
*
19+
* parseCliArgs("--chrome")
20+
* → { flags: { chrome: null }, positionals: [] }
21+
*
22+
* parseCliArgs("--chrome --effort high")
23+
* → { flags: { chrome: null, effort: "high" }, positionals: [] }
24+
*
25+
* parseCliArgs("--effort=high")
26+
* → { flags: { effort: "high" }, positionals: [] }
27+
*
28+
* parseCliArgs(["1.2.3", "--root", "/path", "--github-output"], { booleanFlags: ["github-output"] })
29+
* → { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] }
30+
*/
31+
export function parseCliArgs(
32+
args: string | readonly string[],
33+
options?: ParseCliArgsOptions,
34+
): ParsedCliArgs {
35+
const tokens =
36+
typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args);
37+
const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined;
38+
39+
const flags: Record<string, string | null> = {};
40+
const positionals: string[] = [];
41+
42+
for (let i = 0; i < tokens.length; i++) {
43+
const token = tokens[i]!;
44+
45+
if (token.startsWith("--")) {
46+
const rest = token.slice(2);
47+
if (!rest) continue;
48+
49+
// Handle --key=value syntax
50+
const eqIndex = rest.indexOf("=");
51+
if (eqIndex !== -1) {
52+
flags[rest.slice(0, eqIndex)] = rest.slice(eqIndex + 1);
53+
continue;
54+
}
55+
56+
// Known boolean flag — never consumes next token
57+
if (booleanSet?.has(rest)) {
58+
flags[rest] = null;
59+
continue;
60+
}
61+
62+
// Handle --key value or --flag (boolean)
63+
const next = tokens[i + 1];
64+
if (next !== undefined && !next.startsWith("--")) {
65+
flags[rest] = next;
66+
i++;
67+
} else {
68+
flags[rest] = null;
69+
}
70+
} else {
71+
positionals.push(token);
72+
}
73+
}
74+
75+
return { flags, positionals };
76+
}

0 commit comments

Comments
 (0)