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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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

@akarabachakarabachApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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