Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
70 changes: 69 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterValidationError } from "../Errors.ts";
import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
import { makeClaudeAdapterLive, parseLaunchArgs, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";

class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
private readonly queue: Array<SDKMessage> = [];
Expand Down Expand Up @@ -3219,3 +3219,71 @@ describe("ClaudeAdapterLive", () => {
);
});
});

describe("parseLaunchArgs", () => {
it("returns empty object for empty string", () => {
assert.deepEqual(parseLaunchArgs(""), {});
});

it("returns empty object for whitespace-only string", () => {
assert.deepEqual(parseLaunchArgs(" "), {});
});

it("parses --chrome boolean flag", () => {
assert.deepEqual(parseLaunchArgs("--chrome"), { chrome: null });
});

it("parses --chrome with --verbose", () => {
assert.deepEqual(parseLaunchArgs("--chrome --verbose"), {
chrome: null,
verbose: null,
});
});

it("parses --effort with a value", () => {
assert.deepEqual(parseLaunchArgs("--effort high"), { effort: "high" });
});

it("parses --chrome --effort high --debug", () => {
assert.deepEqual(parseLaunchArgs("--chrome --effort high --debug"), {
chrome: null,
effort: "high",
debug: null,
});
});

it("parses --model with full model name", () => {
assert.deepEqual(parseLaunchArgs("--model claude-sonnet-4-6"), {
model: "claude-sonnet-4-6",
});
});

it("parses --append-system-prompt with value and --chrome", () => {
assert.deepEqual(parseLaunchArgs("--append-system-prompt always-think-step-by-step --chrome"), {
"append-system-prompt": "always-think-step-by-step",
chrome: null,
});
});

it("parses --max-budget-usd with numeric value", () => {
assert.deepEqual(parseLaunchArgs("--chrome --max-budget-usd 5.00"), {
chrome: null,
"max-budget-usd": "5.00",
});
});

it("handles extra whitespace between tokens", () => {
assert.deepEqual(parseLaunchArgs(" --chrome --verbose "), {
chrome: null,
verbose: null,
});
});

it("ignores bare -- with no flag name", () => {
assert.deepEqual(parseLaunchArgs("--"), {});
});

it("ignores tokens that don't start with --", () => {
assert.deepEqual(parseLaunchArgs("chrome"), {});
});
});
31 changes: 31 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,35 @@ const CLAUDE_SETTING_SOURCES = [
"local",
] as const satisfies ReadonlyArray<SettingSource>;

/**
* Parse a CLI-style launch args string into the SDK's `extraArgs` format.
*
* Boolean flags (no value) become `null`, flags with a value become strings:
* "" → {}
* "--chrome" → { chrome: null }
* "--chrome --debug" → { chrome: null, debug: null }
* "--chrome --max-turns 5" → { chrome: null, "max-turns": "5" }
*/
export function parseLaunchArgs(args: string): Record<string, string | null> {
Comment thread
akarabach marked this conversation as resolved.
Outdated
const result: Record<string, string | null> = {};
const tokens = args.trim().split(/\s+/).filter(Boolean);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]!;
if (token.startsWith("--")) {
const key = token.slice(2);
if (!key) continue;
const next = tokens[i + 1];
if (next !== undefined && !next.startsWith("--")) {
result[key] = next;
i++;
} else {
result[key] = null;
}
}
}
return result;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

function buildPromptText(input: ProviderSendTurnInput): string {
const rawEffort =
input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
Expand Down Expand Up @@ -2680,6 +2709,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
),
);
const claudeBinaryPath = claudeSettings.binaryPath;
const extraArgs = parseLaunchArgs(claudeSettings.launchArgs);
const modelSelection =
input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
const caps = getClaudeModelCapabilities(modelSelection?.model);
Expand Down Expand Up @@ -2719,6 +2749,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
Loading