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
8 changes: 8 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ on:
permissions:
contents: write

# @vscode/ripgrep's postinstall hits api.github.com/repos/microsoft/ripgrep-prebuilt/releases
# to find the rg binary URL. Unauthenticated requests are rate-limited to 60/h per IP, and
# Actions runners share IP pools — concurrent runs across the org reliably trip the limit
# and 403 the install. Passing the auto-provided GITHUB_TOKEN flips it into the
# authenticated 5000/h bucket, which is plenty.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
lint-format:
name: Lint & Format
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ permissions:
contents: write
id-token: write

# Authenticate @vscode/ripgrep's postinstall against the GitHub API to
# avoid the 60 req/h anonymous rate limit shared across Actions runners.
# Same reasoning as pr-check.yml — see the note there.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
release:
name: Release
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
"esbuild",
"@vscode/ripgrep"
],
"peerDependencyRules": {
"allowedVersions": {
Expand Down
94 changes: 93 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ import fs from 'node:fs'
import path from 'node:path'

import {
McpPermissionStore,
PROVIDER_DETECTION_ORDER,
PROVIDER_KEY_URLS,
createModelRegistry,
createOAuthProviderFactory,
createSubAgentRegistry,
debugLog,
getAvailableProviders,
getEnvVarName,
getTokenStorage,
listSessions,
loadMcpFromDisk,
loadSession,
loadUserConfig,
pickLatestSession,
resolveModelId,
} from '@x-code-cli/core'
import type { AgentOptions, LoadedSession } from '@x-code-cli/core'
import type { AgentOptions, LoadedSession, McpRegistry } from '@x-code-cli/core'

import { getCleanupFn, getSessionExitInfo, startApp } from './app.js'
import { detectShell, formatPersistCommand } from './shell.js'
Expand Down Expand Up @@ -82,6 +86,12 @@ function checkNodeVersion(): void {
// and the delayed stdout flush made it appear after the shell prompt,
// confusing users.
let shutdownInProgress = false
/** Captured at startup so gracefulShutdown can close MCP servers
* (kill stdio child processes, terminate HTTP transports) on the way
* out. Without this, stdio servers would linger until they noticed
* their parent's stdin closed — usually fine, but explicit shutdown
* is faster and less surprising. */
let mcpRegistryForShutdown: McpRegistry | null = null

// Belt-and-suspenders terminal restore. Runs synchronously before exit so even
// if Ink's unmount is partially broken (e.g. a useEffect cleanup threw, or the
Expand Down Expand Up @@ -118,6 +128,13 @@ async function gracefulShutdown(exitCode: number): Promise<never> {
const cleanup = getCleanupFn()
if (cleanup) cleanup().catch(() => undefined)

// Fire-and-forget MCP shutdown. Stdio servers also clean themselves up
// when their stdin closes, so even if process.exit beats this promise
// the OS reaps the children — this just makes it explicit / faster.
if (mcpRegistryForShutdown) {
mcpRegistryForShutdown.shutdown().catch(() => undefined)
}

resetTerminal()
// Print AFTER resetTerminal so the line lands cleanly above the
// shell prompt — colors are reset, raw mode is off, cursor is
Expand Down Expand Up @@ -276,6 +293,35 @@ async function main() {
const model = providerRegistry.languageModel(modelId as `${string}:${string}`)
const subAgentRegistry = await createSubAgentRegistry()

// MCP: load servers, run trust dialog if project-level config is
// unfamiliar. Done BEFORE Ink mounts so the readline-based trust
// prompt has a clean terminal. The MCP machinery is opt-in: a user
// with no mcpServers in their config pays a single fs.stat (one for
// user config, one for project config) and that's it.
const tokenStorage = getTokenStorage()
const mcpPermissionStore = new McpPermissionStore()
const mcpLoadResult = await loadMcpFromDisk({
cwd: process.cwd(),
askUser: (question, opts) => askInTerminal(question, opts),
oauthProviderFor: createOAuthProviderFactory(tokenStorage, (server, url) => {
console.error(chalk.cyan(`[mcp] Opening browser for ${server}: ${url}`))
}),
onExitRequested: () => process.exit(0),
})
mcpRegistryForShutdown = mcpLoadResult.registry

if (mcpLoadResult.configErrors.length > 0) {
for (const e of mcpLoadResult.configErrors) {
console.error(chalk.yellow(`[mcp] config error in ${e.name}: ${e.message}`))
}
}
if (mcpLoadResult.projectSkipped) {
console.error(chalk.yellow(`[mcp] Project-level MCP servers skipped (not trusted).`))
}
// Preload the always-allow list so the first tool call doesn't pay
// the file-read latency.
await mcpPermissionStore.preload()

const options: AgentOptions = {
modelId,
trustMode: argv.trust,
Expand All @@ -294,6 +340,8 @@ async function main() {
permissionMode: argv.plan ? 'plan' : 'default',
modelRegistry: providerRegistry,
subAgentRegistry,
mcpRegistry: mcpLoadResult.registry,
mcpPermissionStore,
}

// Resume / continue. Three resume entry points:
Expand Down Expand Up @@ -487,6 +535,50 @@ function printNoWebSearchKeyHint(): void {
console.error(` ${dim(`(${shell})`)} ${code(cmd)}\n`)
}

/** Plain-terminal prompt used during startup, before Ink mounts.
* Currently the only caller is the MCP project-level trust dialog —
* loader.ts hands its `askUser` callback an arbitrary list of options
* and expects one of the option labels back.
*
* Falls back gracefully when stdin isn't a TTY (piped input, CI,
* `--print` mode): we return the option whose label looks like
* "skip" if present, otherwise the second option (loader's convention
* is index 1 == safe default). This guarantees we never block waiting
* for input that will never arrive. */
async function askInTerminal(
question: string,
options: Array<{ label: string; description: string }>,
): Promise<string> {
const safeDefault = options.find((o) => /skip/i.test(o.label))?.label ?? options[1]?.label ?? options[0]?.label ?? ''

if (!process.stdin.isTTY || !process.stdout.isTTY) {
return safeDefault
}

const readline = await import('node:readline/promises')

// Render to stderr so the prompt body lands in the same stream as
// other CLI status messages; this keeps stdout clean if someone is
// capturing it (rare during interactive startup but better-safe).
process.stderr.write('\n' + chalk.yellow(question) + '\n')
for (let i = 0; i < options.length; i++) {
const o = options[i]
process.stderr.write(` ${chalk.bold(`${i + 1}.`)} ${o.label} — ${chalk.gray(o.description)}\n`)
}

const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
try {
const answer = await rl.question(`\nChoose [1-${options.length}]: `)
const idx = parseInt(answer.trim(), 10) - 1
if (Number.isFinite(idx) && idx >= 0 && idx < options.length) {
return options[idx].label
}
return safeDefault
} finally {
rl.close()
}
}

function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = ''
Expand Down
Loading
Loading