Skip to content

Commit 7dc83a5

Browse files
authored
Allow selecting a specific shell (#11851)
* Allow selecting a specific shell Add --terminal-shell CLI flag to specify which shell ExecaTerminalProcess uses for inline command execution. The shell path is validated at the CLI layer and passed through the standard settings mechanism (BaseTerminal static getter/setter), matching how all other CLI terminal settings flow through the system. * test(cli): make shell path access test cross-platform
1 parent 9a58f76 commit 7dc83a5

File tree

14 files changed

+200
-1
lines changed

14 files changed

+200
-1
lines changed

apps/cli/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
184184
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
185185
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
186186
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
187+
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
187188
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
188189
| `--consecutive-mistake-limit <n>` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` |
189190
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |

apps/cli/src/agent/__tests__/extension-host.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,22 @@ describe("ExtensionHost", () => {
158158
createTestHost()
159159
expect(process.env.ROO_CLI_RUNTIME).toBe("1")
160160
})
161+
162+
it("should set execaShellPath in initialSettings when terminalShell is provided", () => {
163+
const host = createTestHost({ terminalShell: "/bin/bash" })
164+
const emitSpy = vi.spyOn(host, "emit")
165+
host.markWebviewReady()
166+
const updateSettingsCall = emitSpy.mock.calls.find(
167+
(call) =>
168+
call[0] === "webviewMessage" &&
169+
typeof call[1] === "object" &&
170+
call[1] !== null &&
171+
(call[1] as WebviewMessage).type === "updateSettings",
172+
)
173+
expect(updateSettingsCall).toBeDefined()
174+
const payload = updateSettingsCall?.[1] as WebviewMessage
175+
expect(payload.updatedSettings?.execaShellPath).toBe("/bin/bash")
176+
})
161177
})
162178

163179
describe("webview provider registration", () => {
@@ -238,6 +254,26 @@ describe("ExtensionHost", () => {
238254
)
239255
expect(updateSettingsCall).toBeDefined()
240256
})
257+
258+
it("should force terminalShellIntegrationDisabled when terminalShell is provided", () => {
259+
const host = createTestHost({ terminalShell: "/bin/bash" })
260+
const emitSpy = vi.spyOn(host, "emit")
261+
262+
host.markWebviewReady()
263+
264+
const updateSettingsCall = emitSpy.mock.calls.find(
265+
(call) =>
266+
call[0] === "webviewMessage" &&
267+
typeof call[1] === "object" &&
268+
call[1] !== null &&
269+
(call[1] as WebviewMessage).type === "updateSettings",
270+
)
271+
272+
expect(updateSettingsCall).toBeDefined()
273+
const payload = updateSettingsCall?.[1] as WebviewMessage
274+
expect(payload.type).toBe("updateSettings")
275+
expect(payload.updatedSettings?.terminalShellIntegrationDisabled).toBe(true)
276+
})
241277
})
242278
})
243279

apps/cli/src/agent/extension-host.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export interface ExtensionHostOptions {
8080
ephemeral: boolean
8181
debug: boolean
8282
exitOnComplete: boolean
83+
terminalShell?: string
8384
/**
8485
* When true, exit the process on API request errors instead of retrying.
8586
*/
@@ -257,6 +258,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
257258
this.initialSettings.reasoningEffort = this.options.reasoningEffort
258259
}
259260
}
261+
262+
if (this.options.terminalShell) {
263+
this.initialSettings.terminalShellIntegrationDisabled = true
264+
this.initialSettings.execaShellPath = this.options.terminalShell
265+
}
260266
}
261267

262268
// ==========================================================================

apps/cli/src/commands/cli/run.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/li
2626
import { isRecord } from "@/lib/utils/guards.js"
2727
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
2828
import { runOnboarding } from "@/lib/utils/onboarding.js"
29+
import { validateTerminalShellPath } from "@/lib/utils/shell.js"
2930
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
3031
import { VERSION } from "@/lib/utils/version.js"
3132

@@ -176,6 +177,19 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
176177
process.exit(1)
177178
}
178179

180+
let terminalShell: string | undefined
181+
if (flagOptions.terminalShell !== undefined) {
182+
const validatedTerminalShell = await validateTerminalShellPath(flagOptions.terminalShell)
183+
184+
if (!validatedTerminalShell.valid) {
185+
console.error(
186+
`[CLI] Warning: ignoring --terminal-shell "${flagOptions.terminalShell}" (${validatedTerminalShell.reason})`,
187+
)
188+
} else {
189+
terminalShell = validatedTerminalShell.shellPath
190+
}
191+
}
192+
179193
const extensionHostOptions: ExtensionHostOptions = {
180194
mode: effectiveMode,
181195
reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort,
@@ -190,6 +204,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
190204
ephemeral: flagOptions.ephemeral,
191205
debug: flagOptions.debug,
192206
exitOnComplete: effectiveExitOnComplete,
207+
terminalShell,
193208
}
194209

195210
// Roo Code Cloud Authentication

apps/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ program
4747
.option("--provider <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
4848
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
4949
.option("--mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
50+
.option("--terminal-shell <path>", "Absolute path to shell executable for inline terminal commands")
5051
.option(
5152
"-r, --reasoning-effort <effort>",
5253
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import fs from "fs/promises"
2+
3+
import { validateTerminalShellPath } from "../shell.js"
4+
5+
vi.mock("fs/promises", () => ({
6+
default: {
7+
access: vi.fn(),
8+
stat: vi.fn(),
9+
},
10+
}))
11+
12+
describe("validateTerminalShellPath", () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks()
15+
vi.mocked(fs.access).mockResolvedValue(undefined)
16+
vi.mocked(fs.stat).mockResolvedValue({
17+
isFile: () => true,
18+
} as unknown as Awaited<ReturnType<typeof fs.stat>>)
19+
})
20+
21+
it("returns invalid for an empty path", async () => {
22+
const result = await validateTerminalShellPath(" ")
23+
expect(result).toEqual({ valid: false, reason: "shell path cannot be empty" })
24+
})
25+
26+
it("returns invalid for a relative path", async () => {
27+
const result = await validateTerminalShellPath("bin/bash")
28+
expect(result).toEqual({ valid: false, reason: "shell path must be absolute" })
29+
})
30+
31+
it("returns valid for an absolute executable path", async () => {
32+
const result = await validateTerminalShellPath("/bin/bash")
33+
expect(result).toEqual({ valid: true, shellPath: "/bin/bash" })
34+
})
35+
36+
it("returns invalid when the shell path cannot be accessed", async () => {
37+
vi.mocked(fs.stat).mockRejectedValueOnce(new Error("ENOENT"))
38+
const result = await validateTerminalShellPath("/missing/shell")
39+
40+
expect(result.valid).toBe(false)
41+
if (!result.valid) {
42+
expect(result.reason).toContain("shell path")
43+
}
44+
})
45+
46+
it("returns invalid when the shell path points to a directory", async () => {
47+
vi.mocked(fs.stat).mockResolvedValueOnce({
48+
isFile: () => false,
49+
} as unknown as Awaited<ReturnType<typeof fs.stat>>)
50+
const result = await validateTerminalShellPath("/bin")
51+
52+
expect(result).toEqual({ valid: false, reason: "shell path must point to a file" })
53+
})
54+
})

apps/cli/src/lib/utils/shell.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fs from "fs/promises"
2+
import { constants as fsConstants } from "fs"
3+
import path from "path"
4+
5+
export type TerminalShellValidationResult =
6+
| {
7+
valid: true
8+
shellPath: string
9+
}
10+
| {
11+
valid: false
12+
reason: string
13+
}
14+
15+
export async function validateTerminalShellPath(rawShellPath: string): Promise<TerminalShellValidationResult> {
16+
const shellPath = rawShellPath.trim()
17+
18+
if (!shellPath) {
19+
return { valid: false, reason: "shell path cannot be empty" }
20+
}
21+
22+
if (!path.isAbsolute(shellPath)) {
23+
return { valid: false, reason: "shell path must be absolute" }
24+
}
25+
26+
try {
27+
const stats = await fs.stat(shellPath)
28+
29+
if (!stats.isFile()) {
30+
return { valid: false, reason: "shell path must point to a file" }
31+
}
32+
33+
if (process.platform !== "win32") {
34+
await fs.access(shellPath, fsConstants.X_OK)
35+
}
36+
} catch {
37+
return {
38+
valid: false,
39+
reason:
40+
process.platform === "win32"
41+
? "shell path does not exist or is not a file"
42+
: "shell path does not exist, is not a file, or is not executable",
43+
}
44+
}
45+
46+
return { valid: true, shellPath }
47+
}

apps/cli/src/types/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type FlagOptions = {
3434
provider?: SupportedProvider
3535
model?: string
3636
mode?: string
37+
terminalShell?: string
3738
reasoningEffort?: ReasoningEffortFlagOptions
3839
consecutiveMistakeLimit?: number
3940
ephemeral: boolean

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({
176176
terminalZshOhMy: z.boolean().optional(),
177177
terminalZshP10k: z.boolean().optional(),
178178
terminalZdotdir: z.boolean().optional(),
179+
execaShellPath: z.string().optional(),
179180

180181
diagnosticsEnabled: z.boolean().optional(),
181182

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export type ExtensionState = Pick<
282282
| "terminalZshOhMy"
283283
| "terminalZshP10k"
284284
| "terminalZdotdir"
285+
| "execaShellPath"
285286
| "diagnosticsEnabled"
286287
| "language"
287288
| "modeApiConfigs"

0 commit comments

Comments
 (0)