init claude-code
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { access } from 'fs/promises'
|
||||
import { tmpdir as osTmpdir } from 'os'
|
||||
import { join as nativeJoin } from 'path'
|
||||
import { join as posixJoin } from 'path/posix'
|
||||
import { rearrangePipeCommand } from '../bash/bashPipeCommand.js'
|
||||
import { createAndSaveSnapshot } from '../bash/ShellSnapshot.js'
|
||||
import { formatShellPrefixCommand } from '../bash/shellPrefix.js'
|
||||
import { quote } from '../bash/shellQuote.js'
|
||||
import {
|
||||
quoteShellCommand,
|
||||
rewriteWindowsNullRedirect,
|
||||
shouldAddStdinRedirect,
|
||||
} from '../bash/shellQuoting.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getPlatform } from '../platform.js'
|
||||
import { getSessionEnvironmentScript } from '../sessionEnvironment.js'
|
||||
import { getSessionEnvVars } from '../sessionEnvVars.js'
|
||||
import {
|
||||
ensureSocketInitialized,
|
||||
getClaudeTmuxEnv,
|
||||
hasTmuxToolBeenUsed,
|
||||
} from '../tmuxSocket.js'
|
||||
import { windowsPathToPosixPath } from '../windowsPaths.js'
|
||||
import type { ShellProvider } from './shellProvider.js'
|
||||
|
||||
/**
|
||||
* Returns a shell command to disable extended glob patterns for security.
|
||||
* Extended globs (bash extglob, zsh EXTENDED_GLOB) can be exploited via
|
||||
* malicious filenames that expand after our security validation.
|
||||
*
|
||||
* When CLAUDE_CODE_SHELL_PREFIX is set, the actual executing shell may differ
|
||||
* from shellPath (e.g., shellPath is zsh but the wrapper runs bash). In this
|
||||
* case, we include commands for BOTH shells. We redirect both stdout and stderr
|
||||
* to /dev/null because zsh's command_not_found_handler writes to STDOUT.
|
||||
*
|
||||
* When no shell prefix is set, we use the appropriate command for the detected shell.
|
||||
*/
|
||||
function getDisableExtglobCommand(shellPath: string): string | null {
|
||||
// When CLAUDE_CODE_SHELL_PREFIX is set, the wrapper may use a different shell
|
||||
// than shellPath, so we include both bash and zsh commands
|
||||
if (process.env.CLAUDE_CODE_SHELL_PREFIX) {
|
||||
// Redirect both stdout and stderr because zsh's command_not_found_handler
|
||||
// writes to stdout instead of stderr
|
||||
return '{ shopt -u extglob || setopt NO_EXTENDED_GLOB; } >/dev/null 2>&1 || true'
|
||||
}
|
||||
|
||||
// No shell prefix - use shell-specific command
|
||||
if (shellPath.includes('bash')) {
|
||||
return 'shopt -u extglob 2>/dev/null || true'
|
||||
} else if (shellPath.includes('zsh')) {
|
||||
return 'setopt NO_EXTENDED_GLOB 2>/dev/null || true'
|
||||
}
|
||||
// Unknown shell - do nothing, we don't know the right command
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createBashShellProvider(
|
||||
shellPath: string,
|
||||
options?: { skipSnapshot?: boolean },
|
||||
): Promise<ShellProvider> {
|
||||
let currentSandboxTmpDir: string | undefined
|
||||
const snapshotPromise: Promise<string | undefined> = options?.skipSnapshot
|
||||
? Promise.resolve(undefined)
|
||||
: createAndSaveSnapshot(shellPath).catch(error => {
|
||||
logForDebugging(`Failed to create shell snapshot: ${error}`)
|
||||
return undefined
|
||||
})
|
||||
// Track the last resolved snapshot path for use in getSpawnArgs
|
||||
let lastSnapshotFilePath: string | undefined
|
||||
|
||||
return {
|
||||
type: 'bash',
|
||||
shellPath,
|
||||
detached: true,
|
||||
|
||||
async buildExecCommand(
|
||||
command: string,
|
||||
opts: {
|
||||
id: number | string
|
||||
sandboxTmpDir?: string
|
||||
useSandbox: boolean
|
||||
},
|
||||
): Promise<{ commandString: string; cwdFilePath: string }> {
|
||||
let snapshotFilePath = await snapshotPromise
|
||||
// This access() check is NOT pure TOCTOU — it's the fallback decision
|
||||
// point for getSpawnArgs. When the snapshot disappears mid-session
|
||||
// (tmpdir cleanup), we must clear lastSnapshotFilePath so getSpawnArgs
|
||||
// adds -l and the command gets login-shell init. Without this check,
|
||||
// `source ... || true` silently fails and commands run with NO shell
|
||||
// init (neither snapshot env nor login profile). The `|| true` on source
|
||||
// still guards the race between this check and the spawned shell.
|
||||
if (snapshotFilePath) {
|
||||
try {
|
||||
await access(snapshotFilePath)
|
||||
} catch {
|
||||
logForDebugging(
|
||||
`Snapshot file missing, falling back to login shell: ${snapshotFilePath}`,
|
||||
)
|
||||
snapshotFilePath = undefined
|
||||
}
|
||||
}
|
||||
lastSnapshotFilePath = snapshotFilePath
|
||||
|
||||
// Stash sandboxTmpDir for use in getEnvironmentOverrides
|
||||
currentSandboxTmpDir = opts.sandboxTmpDir
|
||||
|
||||
const tmpdir = osTmpdir()
|
||||
const isWindows = getPlatform() === 'windows'
|
||||
const shellTmpdir = isWindows ? windowsPathToPosixPath(tmpdir) : tmpdir
|
||||
|
||||
// shellCwdFilePath: POSIX path used inside the bash command (pwd -P >| ...)
|
||||
// cwdFilePath: native OS path used by Node.js for readFileSync/unlinkSync
|
||||
// On non-Windows these are identical; on Windows, Git Bash needs POSIX paths
|
||||
// but Node.js needs native Windows paths for file operations.
|
||||
const shellCwdFilePath = opts.useSandbox
|
||||
? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)
|
||||
: posixJoin(shellTmpdir, `claude-${opts.id}-cwd`)
|
||||
const cwdFilePath = opts.useSandbox
|
||||
? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)
|
||||
: nativeJoin(tmpdir, `claude-${opts.id}-cwd`)
|
||||
|
||||
// Defensive rewrite: the model sometimes emits Windows CMD-style `2>nul`
|
||||
// redirects. In POSIX bash (including Git Bash on Windows), this creates a
|
||||
// literal file named `nul` — a reserved device name that breaks git.
|
||||
// See anthropics/claude-code#4928.
|
||||
const normalizedCommand = rewriteWindowsNullRedirect(command)
|
||||
const addStdinRedirect = shouldAddStdinRedirect(normalizedCommand)
|
||||
let quotedCommand = quoteShellCommand(normalizedCommand, addStdinRedirect)
|
||||
|
||||
// Debug logging for heredoc/multiline commands to trace trailer handling
|
||||
// Only log when commit attribution is enabled to avoid noise
|
||||
if (
|
||||
feature('COMMIT_ATTRIBUTION') &&
|
||||
(command.includes('<<') || command.includes('\n'))
|
||||
) {
|
||||
logForDebugging(
|
||||
`Shell: Command before quoting (first 500 chars):\n${command.slice(0, 500)}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`Shell: Quoted command (first 500 chars):\n${quotedCommand.slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Special handling for pipes: move stdin redirect after first command
|
||||
// This ensures the redirect applies to the first command, not to eval itself.
|
||||
// Without this, `eval 'rg foo | wc -l' \< /dev/null` becomes
|
||||
// `rg foo | wc -l < /dev/null` — wc reads /dev/null and outputs 0, and
|
||||
// rg (with no path arg) waits on the open spawn stdin pipe forever.
|
||||
// Applies to sandbox mode too: sandbox wraps the assembled commandString,
|
||||
// not the raw command (since PR #9189).
|
||||
if (normalizedCommand.includes('|') && addStdinRedirect) {
|
||||
quotedCommand = rearrangePipeCommand(normalizedCommand)
|
||||
}
|
||||
|
||||
const commandParts: string[] = []
|
||||
|
||||
// Source the snapshot file. The `|| true` guards the race between the
|
||||
// access() check above and the spawned shell's `source` — if the file
|
||||
// vanishes in that window, the `&&` chain still continues.
|
||||
if (snapshotFilePath) {
|
||||
const finalPath =
|
||||
getPlatform() === 'windows'
|
||||
? windowsPathToPosixPath(snapshotFilePath)
|
||||
: snapshotFilePath
|
||||
commandParts.push(`source ${quote([finalPath])} 2>/dev/null || true`)
|
||||
}
|
||||
|
||||
// Source session environment variables captured from session start hooks
|
||||
const sessionEnvScript = await getSessionEnvironmentScript()
|
||||
if (sessionEnvScript) {
|
||||
commandParts.push(sessionEnvScript)
|
||||
}
|
||||
|
||||
// Disable extended glob patterns for security (after sourcing user config to override)
|
||||
const disableExtglobCmd = getDisableExtglobCommand(shellPath)
|
||||
if (disableExtglobCmd) {
|
||||
commandParts.push(disableExtglobCmd)
|
||||
}
|
||||
|
||||
// When sourcing a file with aliases, they won't be expanded in the same command line
|
||||
// because the shell parses the entire line before execution. Using eval after
|
||||
// sourcing causes a second parsing pass where aliases are now available for expansion.
|
||||
commandParts.push(`eval ${quotedCommand}`)
|
||||
// Use `pwd -P` to get the physical path of the current working directory for consistency with `process.cwd()`
|
||||
commandParts.push(`pwd -P >| ${quote([shellCwdFilePath])}`)
|
||||
let commandString = commandParts.join(' && ')
|
||||
|
||||
// Apply CLAUDE_CODE_SHELL_PREFIX if set
|
||||
if (process.env.CLAUDE_CODE_SHELL_PREFIX) {
|
||||
commandString = formatShellPrefixCommand(
|
||||
process.env.CLAUDE_CODE_SHELL_PREFIX,
|
||||
commandString,
|
||||
)
|
||||
}
|
||||
|
||||
return { commandString, cwdFilePath }
|
||||
},
|
||||
|
||||
getSpawnArgs(commandString: string): string[] {
|
||||
const skipLoginShell = lastSnapshotFilePath !== undefined
|
||||
if (skipLoginShell) {
|
||||
logForDebugging('Spawning shell without login (-l flag skipped)')
|
||||
}
|
||||
return ['-c', ...(skipLoginShell ? [] : ['-l']), commandString]
|
||||
},
|
||||
|
||||
async getEnvironmentOverrides(
|
||||
command: string,
|
||||
): Promise<Record<string, string>> {
|
||||
// TMUX SOCKET ISOLATION (DEFERRED):
|
||||
// We initialize Claude's tmux socket ONLY AFTER the Tmux tool has been used
|
||||
// at least once, OR if the current command appears to use tmux.
|
||||
// This defers the startup cost until tmux is actually needed.
|
||||
//
|
||||
// Once the Tmux tool is used (or a tmux command runs), all subsequent Bash
|
||||
// commands will use Claude's isolated socket via the TMUX env var override.
|
||||
//
|
||||
// See tmuxSocket.ts for the full isolation architecture documentation.
|
||||
const commandUsesTmux = command.includes('tmux')
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
(hasTmuxToolBeenUsed() || commandUsesTmux)
|
||||
) {
|
||||
await ensureSocketInitialized()
|
||||
}
|
||||
const claudeTmuxEnv = getClaudeTmuxEnv()
|
||||
const env: Record<string, string> = {}
|
||||
// CRITICAL: Override TMUX to isolate ALL tmux commands to Claude's socket.
|
||||
// This is NOT the user's TMUX value - it points to Claude's isolated socket.
|
||||
// When null (before socket initializes), user's TMUX is preserved.
|
||||
if (claudeTmuxEnv) {
|
||||
env.TMUX = claudeTmuxEnv
|
||||
}
|
||||
if (currentSandboxTmpDir) {
|
||||
let posixTmpDir = currentSandboxTmpDir
|
||||
if (getPlatform() === 'windows') {
|
||||
posixTmpDir = windowsPathToPosixPath(posixTmpDir)
|
||||
}
|
||||
env.TMPDIR = posixTmpDir
|
||||
env.CLAUDE_CODE_TMPDIR = posixTmpDir
|
||||
// Zsh uses TMPPREFIX (default /tmp/zsh) for heredoc temp files,
|
||||
// not TMPDIR. Set it to a path inside the sandbox tmp dir so
|
||||
// heredocs work in sandboxed zsh commands.
|
||||
// Safe to set unconditionally — non-zsh shells ignore TMPPREFIX.
|
||||
env.TMPPREFIX = posixJoin(posixTmpDir, 'zsh')
|
||||
}
|
||||
// Apply session env vars set via /env (child processes only, not the REPL)
|
||||
for (const [key, value] of getSessionEnvVars()) {
|
||||
env[key] = value
|
||||
}
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { validateBoundedIntEnvVar } from '../envValidation.js'
|
||||
|
||||
export const BASH_MAX_OUTPUT_UPPER_LIMIT = 150_000
|
||||
export const BASH_MAX_OUTPUT_DEFAULT = 30_000
|
||||
|
||||
export function getMaxOutputLength(): number {
|
||||
const result = validateBoundedIntEnvVar(
|
||||
'BASH_MAX_OUTPUT_LENGTH',
|
||||
process.env.BASH_MAX_OUTPUT_LENGTH,
|
||||
BASH_MAX_OUTPUT_DEFAULT,
|
||||
BASH_MAX_OUTPUT_UPPER_LIMIT,
|
||||
)
|
||||
return result.effective
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { realpath, stat } from 'fs/promises'
|
||||
import { getPlatform } from '../platform.js'
|
||||
import { which } from '../which.js'
|
||||
|
||||
async function probePath(p: string): Promise<string | null> {
|
||||
try {
|
||||
return (await stat(p)).isFile() ? p : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to find PowerShell on the system via PATH.
|
||||
* Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1).
|
||||
*
|
||||
* On Linux, if PATH resolves to a snap launcher (/snap/…) — directly or
|
||||
* via a symlink chain like /usr/bin/pwsh → /snap/bin/pwsh — probe known
|
||||
* apt/rpm install locations instead: the snap launcher can hang in
|
||||
* subprocesses while snapd initializes confinement, but the underlying
|
||||
* binary at /opt/microsoft/powershell/7/pwsh is reliable. On
|
||||
* Windows/macOS, PATH is sufficient.
|
||||
*/
|
||||
export async function findPowerShell(): Promise<string | null> {
|
||||
const pwshPath = await which('pwsh')
|
||||
if (pwshPath) {
|
||||
// Snap launcher hangs in subprocesses. Prefer the direct binary.
|
||||
// Check both the resolved PATH entry and its symlink target: on
|
||||
// some distros /usr/bin/pwsh is a symlink to /snap/bin/pwsh, which
|
||||
// would bypass a naive startsWith('/snap/') on the which() result.
|
||||
if (getPlatform() === 'linux') {
|
||||
const resolved = await realpath(pwshPath).catch(() => pwshPath)
|
||||
if (pwshPath.startsWith('/snap/') || resolved.startsWith('/snap/')) {
|
||||
const direct =
|
||||
(await probePath('/opt/microsoft/powershell/7/pwsh')) ??
|
||||
(await probePath('/usr/bin/pwsh'))
|
||||
if (direct) {
|
||||
const directResolved = await realpath(direct).catch(() => direct)
|
||||
if (
|
||||
!direct.startsWith('/snap/') &&
|
||||
!directResolved.startsWith('/snap/')
|
||||
) {
|
||||
return direct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pwshPath
|
||||
}
|
||||
|
||||
const powershellPath = await which('powershell')
|
||||
if (powershellPath) {
|
||||
return powershellPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let cachedPowerShellPath: Promise<string | null> | null = null
|
||||
|
||||
/**
|
||||
* Gets the cached PowerShell path. Returns a memoized promise that
|
||||
* resolves to the PowerShell executable path or null.
|
||||
*/
|
||||
export function getCachedPowerShellPath(): Promise<string | null> {
|
||||
if (!cachedPowerShellPath) {
|
||||
cachedPowerShellPath = findPowerShell()
|
||||
}
|
||||
return cachedPowerShellPath
|
||||
}
|
||||
|
||||
export type PowerShellEdition = 'core' | 'desktop'
|
||||
|
||||
/**
|
||||
* Infers the PowerShell edition from the binary name without spawning.
|
||||
* - `pwsh` / `pwsh.exe` → 'core' (PowerShell 7+: supports `&&`, `||`, `?:`, `??`)
|
||||
* - `powershell` / `powershell.exe` → 'desktop' (Windows PowerShell 5.1:
|
||||
* no pipeline chain operators, stderr-sets-$? bug, UTF-16 default encoding)
|
||||
*
|
||||
* PowerShell 6 (also `pwsh`, no `&&`) has been EOL since 2020 and is not
|
||||
* a realistic install target, so 'core' safely implies 7+ semantics.
|
||||
*
|
||||
* Used by the tool prompt to give version-appropriate syntax guidance so
|
||||
* the model doesn't emit `cmd1 && cmd2` on 5.1 (parser error) or avoid
|
||||
* `&&` on 7+ where it's the correct short-circuiting operator.
|
||||
*/
|
||||
export async function getPowerShellEdition(): Promise<PowerShellEdition | null> {
|
||||
const p = await getCachedPowerShellPath()
|
||||
if (!p) return null
|
||||
// basename without extension, case-insensitive. Covers:
|
||||
// C:\Program Files\PowerShell\7\pwsh.exe
|
||||
// /opt/microsoft/powershell/7/pwsh
|
||||
// C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
|
||||
const base = p
|
||||
.split(/[/\\]/)
|
||||
.pop()!
|
||||
.toLowerCase()
|
||||
.replace(/\.exe$/, '')
|
||||
return base === 'pwsh' ? 'core' : 'desktop'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cached PowerShell path. Only for testing.
|
||||
*/
|
||||
export function resetPowerShellCache(): void {
|
||||
cachedPowerShellPath = null
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { join as posixJoin } from 'path/posix'
|
||||
import { getSessionEnvVars } from '../sessionEnvVars.js'
|
||||
import type { ShellProvider } from './shellProvider.js'
|
||||
|
||||
/**
|
||||
* PowerShell invocation flags + command. Shared by the provider's getSpawnArgs
|
||||
* and the hook spawn path in hooks.ts so the flag set stays in one place.
|
||||
*/
|
||||
export function buildPowerShellArgs(cmd: string): string[] {
|
||||
return ['-NoProfile', '-NonInteractive', '-Command', cmd]
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encode a string as UTF-16LE for PowerShell's -EncodedCommand.
|
||||
* Same encoding the parser uses (parser.ts toUtf16LeBase64). The output
|
||||
* is [A-Za-z0-9+/=] only — survives ANY shell-quoting layer, including
|
||||
* @anthropic-ai/sandbox-runtime's shellquote.quote() which would otherwise
|
||||
* corrupt !$? to \!$? when re-wrapping a single-quoted string in double
|
||||
* quotes. Review 2964609818.
|
||||
*/
|
||||
function encodePowerShellCommand(psCommand: string): string {
|
||||
return Buffer.from(psCommand, 'utf16le').toString('base64')
|
||||
}
|
||||
|
||||
export function createPowerShellProvider(shellPath: string): ShellProvider {
|
||||
let currentSandboxTmpDir: string | undefined
|
||||
|
||||
return {
|
||||
type: 'powershell' as ShellProvider['type'],
|
||||
shellPath,
|
||||
detached: false,
|
||||
|
||||
async buildExecCommand(
|
||||
command: string,
|
||||
opts: {
|
||||
id: number | string
|
||||
sandboxTmpDir?: string
|
||||
useSandbox: boolean
|
||||
},
|
||||
): Promise<{ commandString: string; cwdFilePath: string }> {
|
||||
// Stash sandboxTmpDir for getEnvironmentOverrides (mirrors bashProvider)
|
||||
currentSandboxTmpDir = opts.useSandbox ? opts.sandboxTmpDir : undefined
|
||||
|
||||
// When sandboxed, tmpdir() is not writable — the sandbox only allows
|
||||
// writes to sandboxTmpDir. Put the cwd tracking file there so the
|
||||
// inner pwsh can actually write it. Only applies on Linux/macOS/WSL2;
|
||||
// on Windows native, sandbox is never enabled so this branch is dead.
|
||||
const cwdFilePath =
|
||||
opts.useSandbox && opts.sandboxTmpDir
|
||||
? posixJoin(opts.sandboxTmpDir, `claude-pwd-ps-${opts.id}`)
|
||||
: join(tmpdir(), `claude-pwd-ps-${opts.id}`)
|
||||
const escapedCwdFilePath = cwdFilePath.replace(/'/g, "''")
|
||||
// Exit-code capture: prefer $LASTEXITCODE when a native exe ran.
|
||||
// On PS 5.1, a native command that writes to stderr while the stream
|
||||
// is PS-redirected (e.g. `git push 2>&1`) sets $? = $false even when
|
||||
// the exe returned exit 0 — so `if (!$?)` reports a false positive.
|
||||
// $LASTEXITCODE is $null only when no native exe has run in the
|
||||
// session; in that case fall back to $? for cmdlet-only pipelines.
|
||||
// Tradeoff: `native-ok; cmdlet-fail` now returns 0 (was 1). Reverse
|
||||
// is also true: `native-fail; cmdlet-ok` now returns the native
|
||||
// exit code (was 0 — old logic only looked at $? which the trailing
|
||||
// cmdlet set true). Both rarer than the git/npm/curl stderr case.
|
||||
const cwdTracking = `\n; $_ec = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }\n; (Get-Location).Path | Out-File -FilePath '${escapedCwdFilePath}' -Encoding utf8 -NoNewline\n; exit $_ec`
|
||||
const psCommand = command + cwdTracking
|
||||
|
||||
// Sandbox wraps the returned commandString as `<binShell> -c '<cmd>'` —
|
||||
// hardcoded `-c`, no way to inject -NoProfile -NonInteractive. So for
|
||||
// the sandbox path, build a command that itself invokes pwsh with the
|
||||
// full flag set. Shell.ts passes /bin/sh as the sandbox binShell,
|
||||
// producing: bwrap ... sh -c 'pwsh -NoProfile ... -EncodedCommand ...'.
|
||||
// The non-sandbox path returns the bare PS command; getSpawnArgs() adds
|
||||
// the flags via buildPowerShellArgs().
|
||||
//
|
||||
// -EncodedCommand (base64 UTF-16LE), not -Command: the sandbox runtime
|
||||
// applies its OWN shellquote.quote() on top of whatever we build. Any
|
||||
// string containing ' triggers double-quote mode which escapes ! as \! —
|
||||
// POSIX sh preserves that literally, pwsh parse error. Base64 is
|
||||
// [A-Za-z0-9+/=] — no chars that any quoting layer can corrupt.
|
||||
// Review 2964609818.
|
||||
//
|
||||
// shellPath is POSIX-single-quoted so a space-containing install path
|
||||
// (e.g. /opt/my tools/pwsh) survives the inner `/bin/sh -c` word-split.
|
||||
// Flags and base64 are [A-Za-z0-9+/=-] only — no quoting needed.
|
||||
const commandString = opts.useSandbox
|
||||
? [
|
||||
`'${shellPath.replace(/'/g, `'\\''`)}'`,
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-EncodedCommand',
|
||||
encodePowerShellCommand(psCommand),
|
||||
].join(' ')
|
||||
: psCommand
|
||||
|
||||
return { commandString, cwdFilePath }
|
||||
},
|
||||
|
||||
getSpawnArgs(commandString: string): string[] {
|
||||
return buildPowerShellArgs(commandString)
|
||||
},
|
||||
|
||||
async getEnvironmentOverrides(): Promise<Record<string, string>> {
|
||||
const env: Record<string, string> = {}
|
||||
// Apply session env vars set via /env (child processes only, not
|
||||
// the REPL). Without this, `/env PATH=...` affects Bash tool
|
||||
// commands but not PowerShell — so PyCharm users with a stripped
|
||||
// PATH can't self-rescue.
|
||||
// Ordering: session vars FIRST so the sandbox TMPDIR below can't be
|
||||
// overridden by `/env TMPDIR=...`. bashProvider.ts has these in the
|
||||
// opposite order (pre-existing), but sandbox isolation should win.
|
||||
for (const [key, value] of getSessionEnvVars()) {
|
||||
env[key] = value
|
||||
}
|
||||
if (currentSandboxTmpDir) {
|
||||
// PowerShell on Linux/macOS honors TMPDIR for [System.IO.Path]::GetTempPath()
|
||||
env.TMPDIR = currentSandboxTmpDir
|
||||
env.CLAUDE_CODE_TMPDIR = currentSandboxTmpDir
|
||||
}
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Shared command prefix extraction using Haiku LLM
|
||||
*
|
||||
* This module provides a factory for creating command prefix extractors
|
||||
* that can be used by different shell tools. The core logic
|
||||
* (Haiku query, response validation) is shared, while tool-specific
|
||||
* aspects (examples, pre-checks) are configurable.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk'
|
||||
import type { QuerySource } from '../../constants/querySource.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { queryHaiku } from '../../services/api/claude.js'
|
||||
import { startsWithApiErrorPrefix } from '../../services/api/errors.js'
|
||||
import { memoizeWithLRU } from '../memoize.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { asSystemPrompt } from '../systemPromptType.js'
|
||||
|
||||
/**
|
||||
* Shell executables that must never be accepted as bare prefixes.
|
||||
* Allowing e.g. "bash:*" would let any command through, defeating
|
||||
* the permission system. Includes Unix shells and Windows equivalents.
|
||||
*/
|
||||
const DANGEROUS_SHELL_PREFIXES = new Set([
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
'csh',
|
||||
'tcsh',
|
||||
'ksh',
|
||||
'dash',
|
||||
'cmd',
|
||||
'cmd.exe',
|
||||
'powershell',
|
||||
'powershell.exe',
|
||||
'pwsh',
|
||||
'pwsh.exe',
|
||||
'bash.exe',
|
||||
])
|
||||
|
||||
/**
|
||||
* Result of command prefix extraction
|
||||
*/
|
||||
export type CommandPrefixResult = {
|
||||
/** The detected command prefix, or null if no prefix could be determined */
|
||||
commandPrefix: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Result including subcommand prefixes for compound commands
|
||||
*/
|
||||
export type CommandSubcommandPrefixResult = CommandPrefixResult & {
|
||||
subcommandPrefixes: Map<string, CommandPrefixResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a command prefix extractor
|
||||
*/
|
||||
export type PrefixExtractorConfig = {
|
||||
/** Tool name for logging and warning messages */
|
||||
toolName: string
|
||||
|
||||
/** The policy spec containing examples for Haiku */
|
||||
policySpec: string
|
||||
/** Analytics event name for logging */
|
||||
eventName: string
|
||||
|
||||
/** Query source identifier for the API call */
|
||||
querySource: QuerySource
|
||||
|
||||
/** Optional pre-check function that can short-circuit the Haiku call */
|
||||
preCheck?: (command: string) => CommandPrefixResult | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memoized command prefix extractor function.
|
||||
*
|
||||
* Uses two-layer memoization: the outer memoized function creates the promise
|
||||
* and attaches a .catch handler that evicts the cache entry on rejection.
|
||||
* This prevents aborted or failed Haiku calls from poisoning future lookups.
|
||||
*
|
||||
* Bounded to 200 entries via LRU to prevent unbounded growth in heavy sessions.
|
||||
*
|
||||
* @param config - Configuration for the extractor
|
||||
* @returns A memoized async function that extracts command prefixes
|
||||
*/
|
||||
export function createCommandPrefixExtractor(config: PrefixExtractorConfig) {
|
||||
const { toolName, policySpec, eventName, querySource, preCheck } = config
|
||||
|
||||
const memoized = memoizeWithLRU(
|
||||
(
|
||||
command: string,
|
||||
abortSignal: AbortSignal,
|
||||
isNonInteractiveSession: boolean,
|
||||
): Promise<CommandPrefixResult | null> => {
|
||||
const promise = getCommandPrefixImpl(
|
||||
command,
|
||||
abortSignal,
|
||||
isNonInteractiveSession,
|
||||
toolName,
|
||||
policySpec,
|
||||
eventName,
|
||||
querySource,
|
||||
preCheck,
|
||||
)
|
||||
// Evict on rejection so aborted calls don't poison future turns.
|
||||
// Identity guard: after LRU eviction, a newer promise may occupy
|
||||
// this key; a stale rejection must not delete it.
|
||||
promise.catch(() => {
|
||||
if (memoized.cache.get(command) === promise) {
|
||||
memoized.cache.delete(command)
|
||||
}
|
||||
})
|
||||
return promise
|
||||
},
|
||||
command => command, // memoize by command only
|
||||
200,
|
||||
)
|
||||
|
||||
return memoized
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memoized function to get prefixes for compound commands with subcommands.
|
||||
*
|
||||
* Uses the same two-layer memoization pattern as createCommandPrefixExtractor:
|
||||
* a .catch handler evicts the cache entry on rejection to prevent poisoning.
|
||||
*
|
||||
* @param getPrefix - The single-command prefix extractor (from createCommandPrefixExtractor)
|
||||
* @param splitCommand - Function to split a compound command into subcommands
|
||||
* @returns A memoized async function that extracts prefixes for the main command and all subcommands
|
||||
*/
|
||||
export function createSubcommandPrefixExtractor(
|
||||
getPrefix: ReturnType<typeof createCommandPrefixExtractor>,
|
||||
splitCommand: (command: string) => string[] | Promise<string[]>,
|
||||
) {
|
||||
const memoized = memoizeWithLRU(
|
||||
(
|
||||
command: string,
|
||||
abortSignal: AbortSignal,
|
||||
isNonInteractiveSession: boolean,
|
||||
): Promise<CommandSubcommandPrefixResult | null> => {
|
||||
const promise = getCommandSubcommandPrefixImpl(
|
||||
command,
|
||||
abortSignal,
|
||||
isNonInteractiveSession,
|
||||
getPrefix,
|
||||
splitCommand,
|
||||
)
|
||||
// Evict on rejection so aborted calls don't poison future turns.
|
||||
// Identity guard: after LRU eviction, a newer promise may occupy
|
||||
// this key; a stale rejection must not delete it.
|
||||
promise.catch(() => {
|
||||
if (memoized.cache.get(command) === promise) {
|
||||
memoized.cache.delete(command)
|
||||
}
|
||||
})
|
||||
return promise
|
||||
},
|
||||
command => command, // memoize by command only
|
||||
200,
|
||||
)
|
||||
|
||||
return memoized
|
||||
}
|
||||
|
||||
async function getCommandPrefixImpl(
|
||||
command: string,
|
||||
abortSignal: AbortSignal,
|
||||
isNonInteractiveSession: boolean,
|
||||
toolName: string,
|
||||
policySpec: string,
|
||||
eventName: string,
|
||||
querySource: QuerySource,
|
||||
preCheck?: (command: string) => CommandPrefixResult | null,
|
||||
): Promise<CommandPrefixResult | null> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Run pre-check if provided (e.g., isHelpCommand for Bash)
|
||||
if (preCheck) {
|
||||
const preCheckResult = preCheck(command)
|
||||
if (preCheckResult !== null) {
|
||||
return preCheckResult
|
||||
}
|
||||
}
|
||||
|
||||
let preflightCheckTimeoutId: NodeJS.Timeout | undefined
|
||||
const startTime = Date.now()
|
||||
let result: CommandPrefixResult | null = null
|
||||
|
||||
try {
|
||||
// Log a warning if the pre-flight check takes too long
|
||||
preflightCheckTimeoutId = setTimeout(
|
||||
(tn, nonInteractive) => {
|
||||
const message = `[${tn}Tool] Pre-flight check is taking longer than expected. Run with ANTHROPIC_LOG=debug to check for failed or slow API requests.`
|
||||
if (nonInteractive) {
|
||||
process.stderr.write(jsonStringify({ level: 'warn', message }) + '\n')
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional warning
|
||||
console.warn(chalk.yellow(`⚠️ ${message}`))
|
||||
}
|
||||
},
|
||||
10000, // 10 seconds
|
||||
toolName,
|
||||
isNonInteractiveSession,
|
||||
)
|
||||
|
||||
const useSystemPromptPolicySpec = getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_cork_m4q',
|
||||
false,
|
||||
)
|
||||
|
||||
const response = await queryHaiku({
|
||||
systemPrompt: asSystemPrompt(
|
||||
useSystemPromptPolicySpec
|
||||
? [
|
||||
`Your task is to process ${toolName} commands that an AI coding agent wants to run.\n\n${policySpec}`,
|
||||
]
|
||||
: [
|
||||
`Your task is to process ${toolName} commands that an AI coding agent wants to run.\n\nThis policy spec defines how to determine the prefix of a ${toolName} command:`,
|
||||
],
|
||||
),
|
||||
userPrompt: useSystemPromptPolicySpec
|
||||
? `Command: ${command}`
|
||||
: `${policySpec}\n\nCommand: ${command}`,
|
||||
signal: abortSignal,
|
||||
options: {
|
||||
enablePromptCaching: useSystemPromptPolicySpec,
|
||||
querySource,
|
||||
agents: [],
|
||||
isNonInteractiveSession,
|
||||
hasAppendSystemPrompt: false,
|
||||
mcpTools: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Clear the timeout since the query completed
|
||||
clearTimeout(preflightCheckTimeoutId)
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
const prefix =
|
||||
typeof response.message.content === 'string'
|
||||
? response.message.content
|
||||
: Array.isArray(response.message.content)
|
||||
? (response.message.content.find(_ => _.type === 'text')?.text ??
|
||||
'none')
|
||||
: 'none'
|
||||
|
||||
if (startsWithApiErrorPrefix(prefix)) {
|
||||
logEvent(eventName, {
|
||||
success: false,
|
||||
error:
|
||||
'API error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs,
|
||||
})
|
||||
result = null
|
||||
} else if (prefix === 'command_injection_detected') {
|
||||
// Haiku detected something suspicious - treat as no prefix available
|
||||
logEvent(eventName, {
|
||||
success: false,
|
||||
error:
|
||||
'command_injection_detected' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs,
|
||||
})
|
||||
result = {
|
||||
commandPrefix: null,
|
||||
}
|
||||
} else if (
|
||||
prefix === 'git' ||
|
||||
DANGEROUS_SHELL_PREFIXES.has(prefix.toLowerCase())
|
||||
) {
|
||||
// Never accept bare `git` or shell executables as a prefix
|
||||
logEvent(eventName, {
|
||||
success: false,
|
||||
error:
|
||||
'dangerous_shell_prefix' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs,
|
||||
})
|
||||
result = {
|
||||
commandPrefix: null,
|
||||
}
|
||||
} else if (prefix === 'none') {
|
||||
// No prefix detected
|
||||
logEvent(eventName, {
|
||||
success: false,
|
||||
error:
|
||||
'prefix "none"' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs,
|
||||
})
|
||||
result = {
|
||||
commandPrefix: null,
|
||||
}
|
||||
} else {
|
||||
// Validate that the prefix is actually a prefix of the command
|
||||
|
||||
if (!command.startsWith(prefix)) {
|
||||
// Prefix isn't actually a prefix of the command
|
||||
logEvent(eventName, {
|
||||
success: false,
|
||||
error:
|
||||
'command did not start with prefix' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs,
|
||||
})
|
||||
result = {
|
||||
commandPrefix: null,
|
||||
}
|
||||
} else {
|
||||
logEvent(eventName, {
|
||||
success: true,
|
||||
durationMs,
|
||||
})
|
||||
result = {
|
||||
commandPrefix: prefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
clearTimeout(preflightCheckTimeoutId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getCommandSubcommandPrefixImpl(
|
||||
command: string,
|
||||
abortSignal: AbortSignal,
|
||||
isNonInteractiveSession: boolean,
|
||||
getPrefix: ReturnType<typeof createCommandPrefixExtractor>,
|
||||
splitCommandFn: (command: string) => string[] | Promise<string[]>,
|
||||
): Promise<CommandSubcommandPrefixResult | null> {
|
||||
const subcommands = await splitCommandFn(command)
|
||||
|
||||
const [fullCommandPrefix, ...subcommandPrefixesResults] = await Promise.all([
|
||||
getPrefix(command, abortSignal, isNonInteractiveSession),
|
||||
...subcommands.map(async subcommand => ({
|
||||
subcommand,
|
||||
prefix: await getPrefix(subcommand, abortSignal, isNonInteractiveSession),
|
||||
})),
|
||||
])
|
||||
|
||||
if (!fullCommandPrefix) {
|
||||
return null
|
||||
}
|
||||
|
||||
const subcommandPrefixes = subcommandPrefixesResults.reduce(
|
||||
(acc, { subcommand, prefix }) => {
|
||||
if (prefix) {
|
||||
acc.set(subcommand, prefix)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
new Map<string, CommandPrefixResult>(),
|
||||
)
|
||||
|
||||
return {
|
||||
...fullCommandPrefix,
|
||||
subcommandPrefixes,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
import { getInitialSettings } from '../settings/settings.js'
|
||||
|
||||
/**
|
||||
* Resolve the default shell for input-box `!` commands.
|
||||
*
|
||||
* Resolution order (docs/design/ps-shell-selection.md §4.2):
|
||||
* settings.defaultShell → 'bash'
|
||||
*
|
||||
* Platform default is 'bash' everywhere — we do NOT auto-flip Windows to
|
||||
* PowerShell (would break existing Windows users with bash hooks).
|
||||
*/
|
||||
export function resolveDefaultShell(): 'bash' | 'powershell' {
|
||||
return getInitialSettings().defaultShell ?? 'bash'
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const SHELL_TYPES = ['bash', 'powershell'] as const
|
||||
export type ShellType = (typeof SHELL_TYPES)[number]
|
||||
export const DEFAULT_HOOK_SHELL: ShellType = 'bash'
|
||||
|
||||
export type ShellProvider = {
|
||||
type: ShellType
|
||||
shellPath: string
|
||||
detached: boolean
|
||||
|
||||
/**
|
||||
* Build the full command string including all shell-specific setup.
|
||||
* For bash: source snapshot, session env, disable extglob, eval-wrap, pwd tracking.
|
||||
*/
|
||||
buildExecCommand(
|
||||
command: string,
|
||||
opts: {
|
||||
id: number | string
|
||||
sandboxTmpDir?: string
|
||||
useSandbox: boolean
|
||||
},
|
||||
): Promise<{ commandString: string; cwdFilePath: string }>
|
||||
|
||||
/**
|
||||
* Shell args for spawn (e.g., ['-c', '-l', cmd] for bash).
|
||||
*/
|
||||
getSpawnArgs(commandString: string): string[]
|
||||
|
||||
/**
|
||||
* Extra env vars for this shell type.
|
||||
* May perform async initialization (e.g., tmux socket setup for bash).
|
||||
*/
|
||||
getEnvironmentOverrides(command: string): Promise<Record<string, string>>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||||
import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
|
||||
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
|
||||
import { getPlatform } from '../platform.js'
|
||||
|
||||
export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]
|
||||
|
||||
/**
|
||||
* Runtime gate for PowerShellTool. Windows-only (the permission engine uses
|
||||
* Win32-specific path normalizations). Ant defaults on (opt-out via env=0);
|
||||
* external defaults off (opt-in via env=1).
|
||||
*
|
||||
* Used by tools.ts (tool-list visibility), processBashCommand (! routing),
|
||||
* and promptShellExecution (skill frontmatter routing) so the gate is
|
||||
* consistent across all paths that invoke PowerShellTool.call().
|
||||
*/
|
||||
export function isPowerShellToolEnabled(): boolean {
|
||||
if (getPlatform() !== 'windows') return false
|
||||
return process.env.USER_TYPE === 'ant'
|
||||
? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Fig-spec-driven command prefix extraction.
|
||||
*
|
||||
* Given a command name + args array + its @withfig/autocomplete spec, walks
|
||||
* the spec to find how deep into the args a meaningful prefix extends.
|
||||
* `git -C /repo status --short` → `git status` (spec says -C takes a value,
|
||||
* skip it, find `status` as a known subcommand).
|
||||
*
|
||||
* Pure over (string, string[], CommandSpec) — no parser dependency. Extracted
|
||||
* from src/utils/bash/prefix.ts so PowerShell's extractor can reuse it;
|
||||
* external CLIs (git, npm, kubectl) are shell-agnostic.
|
||||
*/
|
||||
|
||||
import type { CommandSpec } from '../bash/registry.js'
|
||||
|
||||
const URL_PROTOCOLS = ['http://', 'https://', 'ftp://']
|
||||
|
||||
// Overrides for commands whose fig specs aren't available at runtime
|
||||
// (dynamic imports don't work in native/node builds). Without these,
|
||||
// calculateDepth falls back to 2, producing overly broad prefixes.
|
||||
export const DEPTH_RULES: Record<string, number> = {
|
||||
rg: 2, // pattern argument is required despite variadic paths
|
||||
'pre-commit': 2,
|
||||
// CLI tools with deep subcommand trees (e.g. gcloud scheduler jobs list)
|
||||
gcloud: 4,
|
||||
'gcloud compute': 6,
|
||||
'gcloud beta': 6,
|
||||
aws: 4,
|
||||
az: 4,
|
||||
kubectl: 3,
|
||||
docker: 3,
|
||||
dotnet: 3,
|
||||
'git push': 2,
|
||||
}
|
||||
|
||||
const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val])
|
||||
|
||||
// Check if an argument matches a known subcommand (case-insensitive: PS
|
||||
// callers pass original-cased args; fig spec names are lowercase)
|
||||
function isKnownSubcommand(arg: string, spec: CommandSpec | null): boolean {
|
||||
if (!spec?.subcommands?.length) return false
|
||||
const argLower = arg.toLowerCase()
|
||||
return spec.subcommands.some(sub =>
|
||||
Array.isArray(sub.name)
|
||||
? sub.name.some(n => n.toLowerCase() === argLower)
|
||||
: sub.name.toLowerCase() === argLower,
|
||||
)
|
||||
}
|
||||
|
||||
// Check if a flag takes an argument based on spec, or use heuristic
|
||||
function flagTakesArg(
|
||||
flag: string,
|
||||
nextArg: string | undefined,
|
||||
spec: CommandSpec | null,
|
||||
): boolean {
|
||||
// Check if flag is in spec.options
|
||||
if (spec?.options) {
|
||||
const option = spec.options.find(opt =>
|
||||
Array.isArray(opt.name) ? opt.name.includes(flag) : opt.name === flag,
|
||||
)
|
||||
if (option) return !!option.args
|
||||
}
|
||||
// Heuristic: if next arg isn't a flag and isn't a known subcommand, assume it's a flag value
|
||||
if (spec?.subcommands?.length && nextArg && !nextArg.startsWith('-')) {
|
||||
return !isKnownSubcommand(nextArg, spec)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Find the first subcommand by skipping flags and their values
|
||||
function findFirstSubcommand(
|
||||
args: string[],
|
||||
spec: CommandSpec | null,
|
||||
): string | undefined {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (!arg) continue
|
||||
if (arg.startsWith('-')) {
|
||||
if (flagTakesArg(arg, args[i + 1], spec)) i++
|
||||
continue
|
||||
}
|
||||
if (!spec?.subcommands?.length) return arg
|
||||
if (isKnownSubcommand(arg, spec)) return arg
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function buildPrefix(
|
||||
command: string,
|
||||
args: string[],
|
||||
spec: CommandSpec | null,
|
||||
): Promise<string> {
|
||||
const maxDepth = await calculateDepth(command, args, spec)
|
||||
const parts = [command]
|
||||
const hasSubcommands = !!spec?.subcommands?.length
|
||||
let foundSubcommand = false
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (!arg || parts.length >= maxDepth) break
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
// Special case: python -c should stop after -c
|
||||
if (arg === '-c' && ['python', 'python3'].includes(command.toLowerCase()))
|
||||
break
|
||||
|
||||
// Check for isCommand/isModule flags that should be included in prefix
|
||||
if (spec?.options) {
|
||||
const option = spec.options.find(opt =>
|
||||
Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
|
||||
)
|
||||
if (
|
||||
option?.args &&
|
||||
toArray(option.args).some(a => a?.isCommand || a?.isModule)
|
||||
) {
|
||||
parts.push(arg)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// For commands with subcommands, skip global flags to find the subcommand
|
||||
if (hasSubcommands && !foundSubcommand) {
|
||||
if (flagTakesArg(arg, args[i + 1], spec)) i++
|
||||
continue
|
||||
}
|
||||
break // Stop at flags (original behavior)
|
||||
}
|
||||
|
||||
if (await shouldStopAtArg(arg, args.slice(0, i), spec)) break
|
||||
if (hasSubcommands && !foundSubcommand) {
|
||||
foundSubcommand = isKnownSubcommand(arg, spec)
|
||||
}
|
||||
parts.push(arg)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
async function calculateDepth(
|
||||
command: string,
|
||||
args: string[],
|
||||
spec: CommandSpec | null,
|
||||
): Promise<number> {
|
||||
// Find first subcommand by skipping flags and their values
|
||||
const firstSubcommand = findFirstSubcommand(args, spec)
|
||||
const commandLower = command.toLowerCase()
|
||||
const key = firstSubcommand
|
||||
? `${commandLower} ${firstSubcommand.toLowerCase()}`
|
||||
: commandLower
|
||||
if (DEPTH_RULES[key]) return DEPTH_RULES[key]
|
||||
if (DEPTH_RULES[commandLower]) return DEPTH_RULES[commandLower]
|
||||
if (!spec) return 2
|
||||
|
||||
if (spec.options && args.some(arg => arg?.startsWith('-'))) {
|
||||
for (const arg of args) {
|
||||
if (!arg?.startsWith('-')) continue
|
||||
const option = spec.options.find(opt =>
|
||||
Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
|
||||
)
|
||||
if (
|
||||
option?.args &&
|
||||
toArray(option.args).some(arg => arg?.isCommand || arg?.isModule)
|
||||
)
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// Find subcommand spec using the already-found firstSubcommand
|
||||
if (firstSubcommand && spec.subcommands?.length) {
|
||||
const firstSubLower = firstSubcommand.toLowerCase()
|
||||
const subcommand = spec.subcommands.find(sub =>
|
||||
Array.isArray(sub.name)
|
||||
? sub.name.some(n => n.toLowerCase() === firstSubLower)
|
||||
: sub.name.toLowerCase() === firstSubLower,
|
||||
)
|
||||
if (subcommand) {
|
||||
if (subcommand.args) {
|
||||
const subArgs = toArray(subcommand.args)
|
||||
if (subArgs.some(arg => arg?.isCommand)) return 3
|
||||
if (subArgs.some(arg => arg?.isVariadic)) return 2
|
||||
}
|
||||
if (subcommand.subcommands?.length) return 4
|
||||
// Leaf subcommand with NO args declared (git show, git log, git tag):
|
||||
// the 3rd word is transient (SHA, ref, tag name) → dead over-specific
|
||||
// rule like PowerShell(git show 81210f8:*). NOT the isOptional case —
|
||||
// `git fetch` declares optional remote/branch and `git fetch origin`
|
||||
// is tested (bash/prefix.test.ts:912) as intentional remote scoping.
|
||||
if (!subcommand.args) return 2
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.args) {
|
||||
const argsArray = toArray(spec.args)
|
||||
|
||||
if (argsArray.some(arg => arg?.isCommand)) {
|
||||
return !Array.isArray(spec.args) && spec.args.isCommand
|
||||
? 2
|
||||
: Math.min(2 + argsArray.findIndex(arg => arg?.isCommand), 3)
|
||||
}
|
||||
|
||||
if (!spec.subcommands?.length) {
|
||||
if (argsArray.some(arg => arg?.isVariadic)) return 1
|
||||
if (argsArray[0] && !argsArray[0].isOptional) return 2
|
||||
}
|
||||
}
|
||||
|
||||
return spec.args && toArray(spec.args).some(arg => arg?.isDangerous) ? 3 : 2
|
||||
}
|
||||
|
||||
async function shouldStopAtArg(
|
||||
arg: string,
|
||||
args: string[],
|
||||
spec: CommandSpec | null,
|
||||
): Promise<boolean> {
|
||||
if (arg.startsWith('-')) return true
|
||||
|
||||
const dotIndex = arg.lastIndexOf('.')
|
||||
const hasExtension =
|
||||
dotIndex > 0 &&
|
||||
dotIndex < arg.length - 1 &&
|
||||
!arg.substring(dotIndex + 1).includes(':')
|
||||
|
||||
const hasFile = arg.includes('/') || hasExtension
|
||||
const hasUrl = URL_PROTOCOLS.some(proto => arg.startsWith(proto))
|
||||
|
||||
if (!hasFile && !hasUrl) return false
|
||||
|
||||
// Check if we're after a -m flag for python modules
|
||||
if (spec?.options && args.length > 0 && args[args.length - 1] === '-m') {
|
||||
const option = spec.options.find(opt =>
|
||||
Array.isArray(opt.name) ? opt.name.includes('-m') : opt.name === '-m',
|
||||
)
|
||||
if (option?.args && toArray(option.args).some(arg => arg?.isModule)) {
|
||||
return false // Don't stop at module names
|
||||
}
|
||||
}
|
||||
|
||||
// For actual files/URLs, always stop regardless of context
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user