diff --git a/src/cli/handlers/skillsCli.ts b/src/cli/handlers/skillsCli.ts index 4d99156ea..74b32059f 100644 --- a/src/cli/handlers/skillsCli.ts +++ b/src/cli/handlers/skillsCli.ts @@ -14,6 +14,7 @@ const TRAILING_GLOBAL_BOOLEAN_FLAGS = new Set([ '--bare', '--debug', '--debug-to-stderr', + '--yolo', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--disable-slash-commands', diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index 60d0997cd..14974985b 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -565,4 +565,99 @@ describe('Node 24 premature exit regression (issue #1678)', () => { expect(src).toMatch(/await main\(\)/) expect(src).not.toMatch(/^\s*void main\(\)/m) }) + + describe('--yolo alias', () => { + it('is registered on the main command next to the canonical flag', async () => { + const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() + expect(src).toContain( + ".option('--yolo, --dangerously-skip-permissions', 'Bypass all permission checks", + ) + }) + + it('is registered on the ssh stub command', async () => { + const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() + const sshCmd = src.indexOf("program.command('ssh [dir]')") + expect(sshCmd).toBeGreaterThanOrEqual(0) + const sshAction = src.indexOf('.action(async () => {', sshCmd) + const sshBlock = src.slice(sshCmd, sshAction) + expect(sshBlock).toContain( + "--yolo, --dangerously-skip-permissions", + ) + }) + + it('is recognized by the cc:// and ssh raw-argv scans', async () => { + const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() + // cc:// sets remote state via includes(); the rewrites and ssh path strip + // both spellings from the forwarded argv. + expect(src).toContain( + "rawCliArgs.includes('--dangerously-skip-permissions') || rawCliArgs.includes('--yolo')", + ) + expect(src).toContain("arg !== '--dangerously-skip-permissions' && arg !== '--yolo'") + expect(src).toContain( + "if (arg === '--dangerously-skip-permissions' || arg === '--yolo')", + ) + }) + + it('strips both bypass spellings from cc:// and ssh forwarded argv', async () => { + const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() + // Passing both flags at once must not leave one behind as an unknown + // option on the headless `open` subcommand or in the ssh forwarded line. + const ccBlockStart = src.indexOf('Check for cc:// or cc+unix:// URL in argv') + const ccBlockEnd = src.indexOf('// Handle deep link URIs early', ccBlockStart) + const ccBlock = src.slice(ccBlockStart, ccBlockEnd) + const ccOccurrences = + ccBlock.split("'--dangerously-skip-permissions'").length - 1 + + ccBlock.split("'--yolo'").length - 1 + expect(ccOccurrences).toBeGreaterThanOrEqual(4) + + const sshBlockStart = src.indexOf("if (rawCliArgs[0] === 'ssh')") + const sshBlockEnd = src.indexOf('// else: `claude ssh` with no host', sshBlockStart) + const sshBlock = src.slice(sshBlockStart, sshBlockEnd) + expect(sshBlock).toContain( + "if (arg === '--dangerously-skip-permissions' || arg === '--yolo')", + ) + }) + + it('is recognized by the skills leading scan so --yolo skills list routes', async () => { + const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text() + const setStart = src.indexOf('SKILLS_LEADING_BOOLEAN_FLAGS = new Set([') + expect(setStart).toBeGreaterThanOrEqual(0) + const setEnd = src.indexOf(']', setStart) + const setBody = src.slice(setStart, setEnd) + expect(setBody).toContain("'--yolo'") + }) + + it('is recognized by the skills trailing scan so skills list --yolo routes', async () => { + const src = await Bun.file( + `${import.meta.dir}/../cli/handlers/skillsCli.ts`, + ).text() + const setStart = src.indexOf('TRAILING_GLOBAL_BOOLEAN_FLAGS = new Set([') + expect(setStart).toBeGreaterThanOrEqual(0) + const setEnd = src.indexOf(']', setStart) + const setBody = src.slice(setStart, setEnd) + expect(setBody).toContain("'--yolo'") + }) + + it('appears in the built CLI help', async () => { + const fs = await import('node:fs') + const path = await import('node:path') + const cliPath = path.resolve(import.meta.dir, '../../dist/cli.mjs') + expect(fs.existsSync(cliPath)).toBe(true) + + const originalGuard = process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + try { + const proc = Bun.spawn(['node', cliPath, '--help'], { stdout: 'pipe' }) + const text = await new Response(proc.stdout).text() + await proc.exited + expect(text).toContain('--yolo, --dangerously-skip-permissions') + } finally { + if (originalGuard === undefined) { + delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + } else { + process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = originalGuard + } + } + }) + }) }) diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index aa7c6034c..5a944f1a8 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -42,6 +42,7 @@ const SKILLS_LEADING_BOOLEAN_FLAGS = new Set([ '--bare', '--debug', '--debug-to-stderr', + '--yolo', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--disable-slash-commands', diff --git a/src/main.tsx b/src/main.tsx index be9171f38..f3cf6533d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -589,24 +589,22 @@ export async function main() { parseConnectUrl } = await import('./server/parseConnectUrl.js'); const parsed = parseConnectUrl(ccUrl); - _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes('--dangerously-skip-permissions'); + _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes('--dangerously-skip-permissions') || rawCliArgs.includes('--yolo'); if (rawCliArgs.includes('-p') || rawCliArgs.includes('--print')) { - // Headless: rewrite to internal `open` subcommand - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); - if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); - } + // Headless: rewrite to internal `open` subcommand. Strip both the + // canonical flag and its alias — the `open` stub does not register + // either, and passing both would leave one behind as an unknown option. + const stripped = rawCliArgs + .filter((_, i) => i !== ccIdx) + .filter(arg => arg !== '--dangerously-skip-permissions' && arg !== '--yolo'); process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...stripped]; } else { - // Interactive: strip cc:// URL and flags, run main command + // Interactive: strip cc:// URL and both bypass spellings, run main command _pendingConnect.url = parsed.serverUrl; _pendingConnect.authToken = parsed.authToken; - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); - if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); - } + const stripped = rawCliArgs + .filter((_, i) => i !== ccIdx) + .filter(arg => arg !== '--dangerously-skip-permissions' && arg !== '--yolo'); process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; } } @@ -688,10 +686,14 @@ export async function main() { _pendingSSH.local = true; rawCliArgs.splice(localIdx, 1); } - const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions'); - if (dspIdx !== -1) { - _pendingSSH.dangerouslySkipPermissions = true; - rawCliArgs.splice(dspIdx, 1); + // Remove both bypass spellings from the forwarded argv; the remote state + // is carried by _pendingSSH.dangerouslySkipPermissions, not by a flag. + for (let i = rawCliArgs.length - 1; i >= 0; i -= 1) { + const arg = rawCliArgs[i]; + if (arg === '--dangerously-skip-permissions' || arg === '--yolo') { + _pendingSSH.dangerouslySkipPermissions = true; + rawCliArgs.splice(i, 1); + } } const pmIdx = rawCliArgs.indexOf('--permission-mode'); if (pmIdx !== -1 && rawCliArgs[pmIdx + 1] && !rawCliArgs[pmIdx + 1]!.startsWith('-')) { @@ -946,7 +948,7 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--yolo, --dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(value => { return parseMaxTurnsCommanderArgument(value); })).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); @@ -3912,7 +3914,7 @@ async function run(): Promise { // this action it means the argv rewrite didn't fire (e.g. user ran // `claude ssh` with no host) — just print usage. if (feature('SSH_REMOTE')) { - program.command('ssh [dir]').description('Run OpenClaude on a remote host over SSH. Deploys the binary and ' + 'tunnels API auth back through your local machine — no remote setup needed.').option('--permission-mode ', 'Permission mode for the remote session').option('--dangerously-skip-permissions', 'Skip all permission prompts on the remote (dangerous)').option('--local', 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + 'Exercises the auth proxy and unix-socket plumbing without a remote host.').action(async () => { + program.command('ssh [dir]').description('Run OpenClaude on a remote host over SSH. Deploys the binary and ' + 'tunnels API auth back through your local machine — no remote setup needed.').option('--permission-mode ', 'Permission mode for the remote session').option('--yolo, --dangerously-skip-permissions', 'Skip all permission prompts on the remote (dangerous)').option('--local', 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + 'Exercises the auth proxy and unix-socket plumbing without a remote host.').action(async () => { // Argv rewriting in main() should have consumed `ssh ` before // commander runs. Reaching here means host was missing or the // rewrite predicate didn't match. diff --git a/web/src/data/cliFlags.ts b/web/src/data/cliFlags.ts index 27a93936e..61f31ccf8 100644 --- a/web/src/data/cliFlags.ts +++ b/web/src/data/cliFlags.ts @@ -83,7 +83,7 @@ export const flagGroups: FlagGroup[] = [ { flag: '--allowed-tools', arg: '', description: 'Comma or space-separated list of tool rules to allow (e.g. "Bash(git:*) Edit").' }, { flag: '--disallowed-tools', arg: '', description: 'Comma or space-separated list of tool rules to deny.' }, { flag: '--tools', arg: '', description: 'Restrict the built-in tool set: "" disables all tools, "default" enables all, or list names like "Bash,Edit,Read".' }, - { flag: '--dangerously-skip-permissions', description: 'Bypass all permission checks. Recommended only for sandboxes with no internet access.' }, + { flag: '--yolo, --dangerously-skip-permissions', description: 'Bypass all permission checks. Recommended only for sandboxes with no internet access.' }, { flag: '--allow-dangerously-skip-permissions', description: 'Make permission bypass available as an option without enabling it by default.' }, { flag: '--add-dir', arg: '', description: 'Additional directories to allow tool access to.' }, ],