Skip to content

Commit 08a37d0

Browse files
akarabachjuliusmarminge
authored andcommitted
feat: add Launch Args setting for Claude provider (pingdotgg#1971)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent af03160 commit 08a37d0

10 files changed

Lines changed: 345 additions & 34 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type SDKUserMessage,
2020
ModelUsage,
2121
} from "@anthropic-ai/claude-agent-sdk";
22+
import { parseCliArgs } from "@marcode/shared/cliArgs";
2223
import {
2324
ApprovalRequestId,
2425
type CanonicalItemType,
@@ -2992,6 +2993,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29922993
),
29932994
);
29942995
const claudeBinaryPath = claudeSettings.binaryPath;
2996+
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
29952997
const modelSelection =
29962998
input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
29972999
const caps = getClaudeModelCapabilities(modelSelection?.model);
@@ -3040,6 +3042,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
30403042
...(computedAdditionalDirs.length > 0
30413043
? { additionalDirectories: computedAdditionalDirs }
30423044
: {}),
3045+
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
30433046
};
30443047

30453048
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
@@ -939,7 +939,8 @@ export function GeneralSettingsPanel() {
939939
claudeAgent: Boolean(
940940
settings.providers.claudeAgent.binaryPath !==
941941
DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ||
942-
settings.providers.claudeAgent.customModels.length > 0,
942+
settings.providers.claudeAgent.customModels.length > 0 ||
943+
settings.providers.claudeAgent.launchArgs !== "",
943944
),
944945
});
945946
const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
@@ -1692,6 +1693,37 @@ export function GeneralSettingsPanel() {
16921693
</div>
16931694
) : null}
16941695

1696+
{providerCard.provider === "claudeAgent" ? (
1697+
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
1698+
<label htmlFor="provider-install-claudeAgent-launch-args" className="block">
1699+
<span className="text-xs font-medium text-foreground">
1700+
Launch arguments
1701+
</span>
1702+
<Input
1703+
id="provider-install-claudeAgent-launch-args"
1704+
className="mt-1.5"
1705+
value={settings.providers.claudeAgent.launchArgs}
1706+
onChange={(event) =>
1707+
updateSettings({
1708+
providers: {
1709+
...settings.providers,
1710+
claudeAgent: {
1711+
...settings.providers.claudeAgent,
1712+
launchArgs: event.target.value,
1713+
},
1714+
},
1715+
})
1716+
}
1717+
placeholder="e.g. --chrome"
1718+
spellCheck={false}
1719+
/>
1720+
<span className="mt-1 block text-xs text-muted-foreground">
1721+
Additional CLI arguments passed to Claude Code on session start.
1722+
</span>
1723+
</label>
1724+
</div>
1725+
) : null}
1726+
16951727
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
16961728
<div className="text-xs font-medium text-foreground">Models</div>
16971729
<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
@@ -139,6 +139,7 @@ export const ClaudeSettings = Schema.Struct({
139139
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
140140
binaryPath: makeBinaryPathSetting("claude"),
141141
customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
142+
launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
142143
});
143144
export type ClaudeSettings = typeof ClaudeSettings.Type;
144145

@@ -233,6 +234,7 @@ const ClaudeSettingsPatch = Schema.Struct({
233234
enabled: Schema.optionalKey(Schema.Boolean),
234235
binaryPath: Schema.optionalKey(Schema.String),
235236
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
237+
launchArgs: Schema.optionalKey(Schema.String),
236238
});
237239

238240
export const ServerSettingsPatch = Schema.Struct({

packages/shared/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
"./path": {
6464
"types": "./src/path.ts",
6565
"import": "./src/path.ts"
66+
},
67+
"./cliArgs": {
68+
"types": "./src/cliArgs.ts",
69+
"import": "./src/cliArgs.ts"
6670
}
6771
},
6872
"scripts": {
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)