diff --git a/package-lock.json b/package-lock.json index 4ff4cefc5..aeba6e373 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ "mime-types": "^3.0.1", "minimatch": "^10.2.3", "openai": "^6.26.0", - "picocolors": "^1.1.1", "picomatch": "^4.0.3", "playwright": "^1.58.2", "proper-lockfile": "^4.1.2", @@ -899,7 +898,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2616,7 +2614,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2854,7 +2851,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -2868,7 +2866,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -2882,7 +2881,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -2896,7 +2896,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -2910,7 +2911,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -2924,7 +2926,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -2938,7 +2941,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -2952,7 +2956,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -2966,7 +2971,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -2980,7 +2986,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -2994,7 +3001,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -3008,7 +3016,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -3022,7 +3031,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -3036,7 +3046,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -3050,7 +3061,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -3064,7 +3076,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -3078,7 +3091,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -3092,7 +3106,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -3106,7 +3121,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -3120,7 +3136,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -3134,7 +3151,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -3148,7 +3166,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -3162,7 +3181,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -3176,7 +3196,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -3190,7 +3211,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", @@ -4292,7 +4314,8 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/hosted-git-info": { "version": "3.0.5", @@ -4363,7 +4386,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4687,7 +4709,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5560,7 +5581,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5731,6 +5751,7 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -6245,7 +6266,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7104,6 +7124,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -7427,6 +7448,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7434,7 +7456,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7515,6 +7536,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7692,7 +7714,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7702,7 +7723,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7816,6 +7836,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8376,6 +8397,7 @@ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -8670,6 +8692,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -8687,6 +8710,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8704,6 +8728,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8721,6 +8746,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8738,6 +8764,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8755,6 +8782,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8772,6 +8800,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8789,6 +8818,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8806,6 +8836,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8823,6 +8854,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8840,6 +8872,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8857,6 +8890,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8874,6 +8908,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8891,6 +8926,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8908,6 +8944,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8925,6 +8962,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8942,6 +8980,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8959,6 +8998,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8976,6 +9016,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8993,6 +9034,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9010,6 +9052,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -9027,6 +9070,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -9044,6 +9088,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -9061,6 +9106,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -9078,6 +9124,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -9095,6 +9142,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -9106,6 +9154,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9334,7 +9383,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c6ed04296..8dbbb9d7b 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,6 @@ "mime-types": "^3.0.1", "minimatch": "^10.2.3", "openai": "^6.26.0", - "picocolors": "^1.1.1", "picomatch": "^4.0.3", "playwright": "^1.58.2", "proper-lockfile": "^4.1.2", diff --git a/src/cli-web-branch.ts b/src/cli-web-branch.ts index ea8e5c6e0..a4afe765b 100644 --- a/src/cli-web-branch.ts +++ b/src/cli-web-branch.ts @@ -5,10 +5,11 @@ import { getProjectSessionsDir } from './project-sessions.js' import { launchWebMode, stopWebMode, type WebModeLaunchStatus, type WebModeStopOptions, type WebModeStopResult } from './web-mode.js' export interface CliFlags { - mode?: 'text' | 'json' | 'rpc' + mode?: 'text' | 'json' | 'rpc' | 'mcp' print?: boolean continue?: boolean noSession?: boolean + worktree?: boolean | string model?: string listModels?: string | true extensions: string[] @@ -24,8 +25,9 @@ export interface CliFlags { webPort?: number /** Additional allowed origins for CORS: `--allowed-origins http://192.168.1.10:8080` */ webAllowedOrigins?: string[] - help?: boolean - version?: boolean + + /** Set by `gsd sessions` when the user picks a specific session to resume */ + _selectedSessionPath?: string } type WritableLike = Pick @@ -47,13 +49,20 @@ export function parseCliArgs(argv: string[]): CliFlags { const arg = args[i] if (arg === '--mode' && i + 1 < args.length) { const mode = args[++i] - if (mode === 'text' || mode === 'json' || mode === 'rpc') flags.mode = mode + if (mode === 'text' || mode === 'json' || mode === 'rpc' || mode === 'mcp') flags.mode = mode } else if (arg === '--print' || arg === '-p') { flags.print = true } else if (arg === '--continue' || arg === '-c') { flags.continue = true } else if (arg === '--no-session') { flags.noSession = true + } else if (arg === '--worktree' || arg === '-w') { + // -w with no value → auto-generate name; -w → use that name + if (i + 1 < args.length && !args[i + 1].startsWith('-')) { + flags.worktree = args[++i] + } else { + flags.worktree = true + } } else if (arg === '--web') { flags.web = true // Peek at next arg — if it looks like a path (not another flag), capture it @@ -81,10 +90,6 @@ export function parseCliArgs(argv: string[]): CliFlags { flags.tools = args[++i].split(',') } else if (arg === '--list-models') { flags.listModels = (i + 1 < args.length && !args[i + 1].startsWith('-')) ? args[++i] : true - } else if (arg === '--version' || arg === '-v') { - flags.version = true - } else if (arg === '--help' || arg === '-h') { - flags.help = true } else if (!arg.startsWith('--') && !arg.startsWith('-')) { flags.messages.push(arg) } diff --git a/src/cli.ts b/src/cli.ts index a361e73db..18da35109 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,14 +16,15 @@ import { agentDir, sessionsDir, authFilePath } from './app-paths.js' import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js' import { ensureManagedTools } from './tool-bootstrap.js' import { loadStoredEnvKeys } from './wizard.js' -import { migratePiCredentials, getPiDefaultModelAndProvider } from './pi-migration.js' +import { migratePiCredentials } from './pi-migration.js' import { shouldRunOnboarding, runOnboarding } from './onboarding.js' import chalk from 'chalk' import { checkForUpdates } from './update-check.js' -import { printHelp, printSubcommandHelp } from './help-text.js' +import { printSubcommandHelp } from './help-text.js' import { applySecurityOverrides } from './security-overrides.js' +import { validateConfiguredModel } from './startup-model-validation.js' import { - parseCliArgs as parseWebCliArgs, + parseCliArgs, runWebCliBranch, migrateLegacyFlatSessions, } from './cli-web-branch.js' @@ -42,28 +43,6 @@ if (parseInt(process.versions.node) >= 22) { process.env.NODE_COMPILE_CACHE ??= join(agentDir, '.compile-cache') } -// --------------------------------------------------------------------------- -// Minimal CLI arg parser — detects print/subagent mode flags -// --------------------------------------------------------------------------- -interface CliFlags { - mode?: 'text' | 'json' | 'rpc' | 'mcp' - print?: boolean - continue?: boolean - noSession?: boolean - worktree?: boolean | string - model?: string - listModels?: string | true - extensions: string[] - appendSystemPrompt?: string - tools?: string[] - messages: string[] - web?: boolean - webPath?: string - - /** Set by `gsd sessions` when the user picks a specific session to resume */ - _selectedSessionPath?: string -} - function exitIfManagedResourcesAreNewer(currentAgentDir: string): void { const currentVersion = process.env.GSD_VERSION || '0.0.0' const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion) @@ -79,124 +58,98 @@ function exitIfManagedResourcesAreNewer(currentAgentDir: string): void { process.exit(1) } -function parseCliArgs(argv: string[]): CliFlags { - const flags: CliFlags = { extensions: [], messages: [] } - const args = argv.slice(2) // skip node + script - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg === '--mode' && i + 1 < args.length) { - const m = args[++i] - if (m === 'text' || m === 'json' || m === 'rpc' || m === 'mcp') flags.mode = m - } else if (arg === '--print' || arg === '-p') { - flags.print = true - } else if (arg === '--continue' || arg === '-c') { - flags.continue = true - } else if (arg === '--no-session') { - flags.noSession = true - } else if (arg === '--model' && i + 1 < args.length) { - flags.model = args[++i] - } else if (arg === '--extension' && i + 1 < args.length) { - flags.extensions.push(args[++i]) - } else if (arg === '--append-system-prompt' && i + 1 < args.length) { - flags.appendSystemPrompt = args[++i] - } else if (arg === '--tools' && i + 1 < args.length) { - flags.tools = args[++i].split(',') - } else if (arg === '--list-models') { - flags.listModels = (i + 1 < args.length && !args[i + 1].startsWith('-')) ? args[++i] : true - } else if (arg === '--version' || arg === '-v') { - process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n') - process.exit(0) - } else if (arg === '--worktree' || arg === '-w') { - // -w with no value → auto-generate name; -w → use that name - if (i + 1 < args.length && !args[i + 1].startsWith('-')) { - flags.worktree = args[++i] - } else { - flags.worktree = true - } - } else if (arg === '--help' || arg === '-h') { - printHelp(process.env.GSD_VERSION || '0.0.0') - process.exit(0) - } else if (arg === '--web') { - flags.web = true - // Capture optional project path after --web (not a flag) - if (i + 1 < args.length && !args[i + 1].startsWith('-')) { - flags.webPath = args[++i] - } - } else if (!arg.startsWith('--') && !arg.startsWith('-')) { - flags.messages.push(arg) - } +// --------------------------------------------------------------------------- +// Shared helpers used by both the print and interactive code paths +// --------------------------------------------------------------------------- + +/** + * Print the non-interactive-mode error and exit. Called both from the early + * TTY gate (before heavy init) and from the interactive-mode TTY gate right + * before `InteractiveMode.run()`. The `includeWebHint` variant also lists + * `--web` and `headless` as alternatives. + */ +function printNonTtyErrorAndExit(missing: string | undefined, includeWebHint: boolean): never { + const suffix = missing ? ` but ${missing} not a TTY` : '' + process.stderr.write(`[gsd] Error: Interactive mode requires a terminal (TTY)${suffix}.\n`) + process.stderr.write('[gsd] Non-interactive alternatives:\n') + process.stderr.write('[gsd] gsd auto Auto-mode (pipeable, no TUI)\n') + process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n') + if (includeWebHint) { + process.stderr.write('[gsd] gsd --web [path] Browser-only web mode\n') } - return flags + process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n') + process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n') + process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n') + if (includeWebHint) { + process.stderr.write('[gsd] gsd headless Auto-mode without TUI\n') + } + process.exit(1) } /** - * Validate the configured default model against the registry and reset it if - * it no longer exists. Must run AFTER extensions have registered their - * providers so that extension models (e.g. pi-claude-cli) are visible. + * Print extension load/conflict errors from an extensions result. Downgrades + * conflicts with built-in tools to warnings (#1347). */ -function validateConfiguredModel( +function printExtensionErrors(errors: ReadonlyArray<{ error: string }>): void { + for (const err of errors) { + const isConflict = err.error.includes('supersedes') || err.error.includes('conflicts with') + const prefix = isConflict ? 'Extension conflict' : 'Extension load error' + process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`) + } +} + +/** + * Re-apply the validated model to the session when `createAgentSession()` + * reports that it had to use a fallback. Prevents silently overriding the + * persisted model of resumed conversations (#3534). + */ +async function reapplyValidatedModelOnFallback( + session: { setModel(model: { provider: string; id: string }): unknown | Promise }, modelRegistry: ModelRegistry, settingsManager: SettingsManager, -): void { - const configuredProvider = settingsManager.getDefaultProvider() - const configuredModel = settingsManager.getDefaultModel() - const allModels = modelRegistry.getAll() - const availableModels = modelRegistry.getAvailable() - const configuredExists = configuredProvider && configuredModel && - allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel) - const configuredAvailable = configuredProvider && configuredModel && - availableModels.some((m) => m.provider === configuredProvider && m.id === configuredModel) - - if (!configuredModel || !configuredExists) { - // Model not configured at all, or removed from registry — pick a fallback. - // Only fires when the model is genuinely unknown (not just temporarily unavailable). - const piDefault = getPiDefaultModelAndProvider() - const preferred = - (piDefault - ? availableModels.find((m) => m.provider === piDefault.provider && m.id === piDefault.model) - : undefined) || - availableModels.find((m) => m.provider === 'openai' && m.id === 'gpt-5.4') || - availableModels.find((m) => m.provider === 'openai') || - availableModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') || - availableModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) || - availableModels.find((m) => m.provider === 'anthropic') || - availableModels[0] - if (preferred) { - settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id) - } - } - - if (settingsManager.getDefaultThinkingLevel() !== 'off' && !configuredExists) { - settingsManager.setDefaultThinkingLevel('off') + fallbackMessage: string | undefined, +): Promise { + if (!fallbackMessage) return + const validatedProvider = settingsManager.getDefaultProvider() + const validatedModelId = settingsManager.getDefaultModel() + if (!validatedProvider || !validatedModelId) return + const correctModel = modelRegistry.getAvailable() + .find((m) => m.provider === validatedProvider && m.id === validatedModelId) + if (!correctModel) return + try { + await session.setModel(correctModel) + } catch { + // Provider not ready — leave session on its current model } } const cliFlags = parseCliArgs(process.argv) const isPrintMode = cliFlags.print || cliFlags.mode !== undefined -// Early resource-skew check — must run before TTY gate so version mismatch -// errors surface even in non-TTY environments. -async function ensureRtkBootstrap(): Promise { - if ((ensureRtkBootstrap as { _done?: boolean })._done) return - +// RTK bootstrap — runs once per process, memoized via a module-level promise +// so concurrent callers await the same initialization. +let rtkBootstrapPromise: Promise | undefined +async function doRtkBootstrap(): Promise { // RTK is opt-in via experimental.rtk preference. Default: disabled. // Honor GSD_RTK_DISABLED if already explicitly set in the environment // (env var takes precedence over preferences for manual override). if (!process.env[GSD_RTK_DISABLED_ENV]) { - const prefs = loadEffectiveGSDPreferences(); - const rtkEnabled = prefs?.preferences.experimental?.rtk === true; + const prefs = loadEffectiveGSDPreferences() + const rtkEnabled = prefs?.preferences.experimental?.rtk === true if (!rtkEnabled) { - process.env[GSD_RTK_DISABLED_ENV] = "1"; + process.env[GSD_RTK_DISABLED_ENV] = '1' } } const rtkStatus = await bootstrapRtk() - ;(ensureRtkBootstrap as { _done?: boolean })._done = true markStartup('bootstrapRtk') if (!rtkStatus.available && rtkStatus.supported && rtkStatus.enabled && rtkStatus.reason) { process.stderr.write(`[gsd] Warning: RTK unavailable — continuing without shell-command compression (${rtkStatus.reason}).\n`) } } +function ensureRtkBootstrap(): Promise { + return (rtkBootstrapPromise ??= doRtkBootstrap()) +} // `gsd update` — update to the latest version via npm if (cliFlags.messages[0] === 'update') { @@ -211,14 +164,7 @@ exitIfManagedResourcesAreNewer(agentDir) // handles that prevent process.exit() from completing promptly. const hasSubcommand = cliFlags.messages.length > 0 if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels && !cliFlags.web) { - process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n') - process.stderr.write('[gsd] Non-interactive alternatives:\n') - process.stderr.write('[gsd] gsd auto Auto-mode (pipeable, no TUI)\n') - process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n') - process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n') - process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n') - process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n') - process.exit(1) + printNonTtyErrorAndExit(undefined, false) } // `gsd --help` — show subcommand-specific help @@ -252,8 +198,7 @@ if (cliFlags.messages[0] === 'config') { // `gsd web stop [path|all]` — stop web server before anything else if (cliFlags.messages[0] === 'web' && cliFlags.messages[1] === 'stop') { - const webFlags = parseWebCliArgs(process.argv) - const webBranch = await runWebCliBranch(webFlags, { + const webBranch = await runWebCliBranch(cliFlags, { stopWebMode, stderr: process.stderr, baseSessionsDir: sessionsDir, @@ -267,8 +212,7 @@ if (cliFlags.messages[0] === 'web' && cliFlags.messages[1] === 'stop') { // `gsd --web [path]` or `gsd web [start] [path]` — launch browser-only web mode if (cliFlags.web || (cliFlags.messages[0] === 'web' && cliFlags.messages[1] !== 'stop')) { await ensureRtkBootstrap() - const webFlags = parseWebCliArgs(process.argv) - const webBranch = await runWebCliBranch(webFlags, { + const webBranch = await runWebCliBranch(cliFlags, { stderr: process.stderr, baseSessionsDir: sessionsDir, agentDir, @@ -356,21 +300,24 @@ if (cliFlags.messages[0] === 'headless') { process.exit(0) } +/** + * Run a headless command by invoking the headless entrypoint with a synthetic + * argv. Shared by the `auto` shorthand (#2732) and the auto-piped-stdout + * redirect so they use the same bootstrap + dynamic-import dance. + */ +async function runHeadlessFromAuto(headlessArgs: string[]): Promise { + await ensureRtkBootstrap() + const { runHeadless, parseHeadlessArgs } = await import('./headless.js') + const argv = [process.argv[0], process.argv[1], 'headless', ...headlessArgs] + await runHeadless(parseHeadlessArgs(argv)) + process.exit(0) +} + // `gsd auto [args...]` — shorthand for `gsd headless auto [args...]` (#2732) // Without this, `gsd auto` falls through to the interactive TUI which hangs // when stdin/stdout are piped (non-TTY environments). if (cliFlags.messages[0] === 'auto') { - await ensureRtkBootstrap() - const { runHeadless, parseHeadlessArgs } = await import('./headless.js') - // Rewrite argv so parseHeadlessArgs sees: [node, gsd, headless, auto, ...rest] - const rewrittenArgv = [ - process.argv[0], - process.argv[1], - 'headless', - ...cliFlags.messages, // ['auto', ...extra args] - ] - await runHeadless(parseHeadlessArgs(rewrittenArgv)) - process.exit(0) + await runHeadlessFromAuto(cliFlags.messages) } // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems @@ -535,39 +482,8 @@ if (isPrintMode) { // Before this, extension-provided models (e.g. claude-code/*) were not yet in the // registry, causing the user's valid choice to be silently overwritten. validateConfiguredModel(modelRegistry, settingsManager) - - // Re-apply the validated model to the session only when findInitialModel() used a - // fallback (not when restoring an existing session's model). This prevents silently - // overriding the persisted model of resumed conversations (#3534). - if (modelFallbackMessage) { - const validatedProvider = settingsManager.getDefaultProvider() - const validatedModelId = settingsManager.getDefaultModel() - if (validatedProvider && validatedModelId) { - const correctModel = modelRegistry.getAvailable() - .find((m) => m.provider === validatedProvider && m.id === validatedModelId) - if (correctModel) { - try { - await session.setModel(correctModel) - } catch { - // Provider not ready — leave session on its current model - } - } - } - } - - if (extensionsResult.errors.length > 0) { - for (const err of extensionsResult.errors) { - // Downgrade conflicts with built-in tools to warnings (#1347) - const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with"); - const prefix = isConflict ? "Extension conflict" : "Extension load error"; - process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`) - } - } - - // Validate configured model now that extension providers are registered. - // Must run after createAgentSession() which flushes pendingProviderRegistrations - // so extension models (e.g. pi-claude-cli) are visible in the registry. - validateConfiguredModel(modelRegistry, settingsManager) + await reapplyValidatedModelOnFallback(session, modelRegistry, settingsManager, modelFallbackMessage) + printExtensionErrors(extensionsResult.errors) // Apply --model override if specified if (cliFlags.model) { @@ -666,11 +582,8 @@ if (!cliFlags.worktree && !isPrintMode) { // which handles non-interactive output gracefully. // --------------------------------------------------------------------------- if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) { - await ensureRtkBootstrap() - const { runHeadless, parseHeadlessArgs } = await import('./headless.js') process.stderr.write('[gsd] stdout is not a terminal — running auto-mode in headless mode.\n') - await runHeadless(parseHeadlessArgs(['node', 'gsd', 'headless', ...cliFlags.messages.slice(1)])) - process.exit(0) + await runHeadlessFromAuto(cliFlags.messages.slice(1)) } // --------------------------------------------------------------------------- @@ -724,38 +637,8 @@ markStartup('createAgentSession') // Before this, extension-provided models (e.g. claude-code/*) were not yet in the // registry, causing the user's valid choice to be silently overwritten. validateConfiguredModel(modelRegistry, settingsManager) - -// Re-apply the validated model to the session only when findInitialModel() used a -// fallback (not when restoring an existing session's model). This prevents silently -// overriding the persisted model of resumed conversations (#3534). -if (interactiveFallbackMsg) { - const validatedProvider = settingsManager.getDefaultProvider() - const validatedModelId = settingsManager.getDefaultModel() - if (validatedProvider && validatedModelId) { - const correctModel = modelRegistry.getAvailable() - .find((m) => m.provider === validatedProvider && m.id === validatedModelId) - if (correctModel) { - try { - await session.setModel(correctModel) - } catch { - // Provider not ready — leave session on its current model - } - } - } -} - -if (extensionsResult.errors.length > 0) { - for (const err of extensionsResult.errors) { - const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with"); - const prefix = isConflict ? "Extension conflict" : "Extension load error"; - process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`) - } -} - -// Validate configured model now that extension providers are registered. -// Must run after createAgentSession() which flushes pendingProviderRegistrations -// so extension models (e.g. pi-claude-cli) are visible in the registry. -validateConfiguredModel(modelRegistry, settingsManager) +await reapplyValidatedModelOnFallback(session, modelRegistry, settingsManager, interactiveFallbackMsg) +printExtensionErrors(extensionsResult.errors) // Restore scoped models from settings on startup. // The upstream InteractiveMode reads enabledModels from settings when /scoped-models is opened, @@ -806,16 +689,7 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { : !process.stdin.isTTY ? 'stdin is' : 'stdout is' - process.stderr.write(`[gsd] Error: Interactive mode requires a terminal (TTY) but ${missing} not a TTY.\n`) - process.stderr.write('[gsd] Non-interactive alternatives:\n') - process.stderr.write('[gsd] gsd auto Auto-mode (pipeable, no TUI)\n') - process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n') - process.stderr.write('[gsd] gsd --web [path] Browser-only web mode\n') - process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n') - process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n') - process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n') - process.stderr.write('[gsd] gsd headless Auto-mode without TUI\n') - process.exit(1) + printNonTtyErrorAndExit(missing, true) } // Welcome screen — shown on every fresh interactive session before TUI takes over. diff --git a/src/logo.ts b/src/logo.ts index dc89200ad..c172fbaf9 100644 --- a/src/logo.ts +++ b/src/logo.ts @@ -19,7 +19,7 @@ export const GSD_LOGO: readonly string[] = [ /** * Render the logo block with a color function applied to each line. * - * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan + * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or chalk.cyan * @returns Ready-to-write string with leading/trailing newlines. */ export function renderLogo(color: (s: string) => string): string { diff --git a/src/onboarding.ts b/src/onboarding.ts index a47d29498..d3e2ba5cf 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -100,8 +100,8 @@ const OTHER_PROVIDERS = [ // ─── Dynamic imports ────────────────────────────────────────────────────────── /** - * Dynamically import @clack/prompts and picocolors. - * Dynamic import with fallback so the module doesn't crash if they're missing. + * Dynamically import @clack/prompts. + * Dynamic import with fallback so the module doesn't crash if it's missing. */ async function loadClack(): Promise { try { @@ -111,10 +111,23 @@ async function loadClack(): Promise { } } +/** + * Build the PicoModule color surface from chalk. Chalk is already a + * dependency of the CLI; this adapter keeps the onboarding call sites stable + * while removing the redundant picocolors dep. + */ async function loadPico(): Promise { try { - const mod = await import('picocolors') - return mod.default ?? mod + const { default: chalk } = await import('chalk') + return { + cyan: (s: string) => chalk.cyan(s), + green: (s: string) => chalk.green(s), + yellow: (s: string) => chalk.yellow(s), + dim: (s: string) => chalk.dim(s), + bold: (s: string) => chalk.bold(s), + red: (s: string) => chalk.red(s), + reset: (s: string) => chalk.reset(s), + } } catch { // Fallback: return identity functions const identity = (s: string) => s @@ -135,9 +148,34 @@ function openBrowser(url: string): void { } } -/** Check if an error is a clack cancel signal */ -function isCancelError(p: ClackModule, err: unknown): boolean { - return p.isCancel(err) +/** Sentinel returned by runStep when the user cancels — tells the caller + * to abort the entire wizard. */ +const STEP_CANCELLED = Symbol('step-cancelled') +type StepCancelled = typeof STEP_CANCELLED + +/** + * Run a single onboarding step with shared error handling: + * - user cancel (Ctrl+C) → p.cancel(cancelMessage), returns STEP_CANCELLED + * - other error → p.log.warn + optional info follow-up, returns null + * - success → the step's return value + */ +async function runStep( + p: ClackModule, + warnLabel: string, + fn: () => Promise, + opts: { cancelMessage?: string; errorInfo?: string } = {}, +): Promise { + try { + return await fn() + } catch (err) { + if (p.isCancel(err)) { + p.cancel(opts.cancelMessage ?? 'Setup cancelled.') + return STEP_CANCELLED + } + p.log.warn(`${warnLabel}: ${err instanceof Error ? err.message : String(err)}`) + if (opts.errorInfo) p.log.info(opts.errorInfo) + return null + } } // ─── Public API ─────────────────────────────────────────────────────────────── @@ -191,54 +229,30 @@ export async function runOnboarding(authStorage: AuthStorage): Promise { p.intro(pc.bold('Welcome to GSD — let\'s get you set up')) // ── LLM Provider Selection ──────────────────────────────────────────────── - let llmConfigured = false - try { - llmConfigured = await runLlmStep(p, pc, authStorage) - } catch (err) { - // User cancelled (Ctrl+C in clack throws) or unexpected error - if (isCancelError(p, err)) { - p.cancel('Setup cancelled — you can run /login inside GSD later.') - return - } - p.log.warn(`LLM setup failed: ${err instanceof Error ? err.message : String(err)}`) - p.log.info('You can configure your LLM provider later with /login inside GSD.') - } + const llmResult = await runStep(p, 'LLM setup failed', () => runLlmStep(p, pc, authStorage), { + cancelMessage: 'Setup cancelled — you can run /login inside GSD later.', + errorInfo: 'You can configure your LLM provider later with /login inside GSD.', + }) + if (llmResult === STEP_CANCELLED) return + const llmConfigured = llmResult ?? false // ── Web Search Provider ────────────────────────────────────────────────── - let searchConfigured: string | null = null - try { - searchConfigured = await runWebSearchStep(p, pc, authStorage, llmConfigured) - } catch (err) { - if (isCancelError(p, err)) { - p.cancel('Setup cancelled.') - return - } - p.log.warn(`Web search setup failed: ${err instanceof Error ? err.message : String(err)}`) - } + const searchResult = await runStep(p, 'Web search setup failed', + () => runWebSearchStep(p, pc, authStorage, llmConfigured)) + if (searchResult === STEP_CANCELLED) return + const searchConfigured = searchResult // ── Remote Questions ───────────────────────────────────────────────────── - let remoteConfigured: string | null = null - try { - remoteConfigured = await runRemoteQuestionsStep(p, pc, authStorage) - } catch (err) { - if (isCancelError(p, err)) { - p.cancel('Setup cancelled.') - return - } - p.log.warn(`Remote questions setup failed: ${err instanceof Error ? err.message : String(err)}`) - } + const remoteResult = await runStep(p, 'Remote questions setup failed', + () => runRemoteQuestionsStep(p, pc, authStorage)) + if (remoteResult === STEP_CANCELLED) return + const remoteConfigured = remoteResult // ── Tool API Keys ───────────────────────────────────────────────────────── - let toolKeyCount = 0 - try { - toolKeyCount = await runToolKeysStep(p, pc, authStorage) - } catch (err) { - if (isCancelError(p, err)) { - p.cancel('Setup cancelled.') - return - } - p.log.warn(`Tool key setup failed: ${err instanceof Error ? err.message : String(err)}`) - } + const toolResult = await runStep(p, 'Tool key setup failed', + () => runToolKeysStep(p, pc, authStorage)) + if (toolResult === STEP_CANCELLED) return + const toolKeyCount = toolResult ?? 0 // ── Summary ─────────────────────────────────────────────────────────────── const summaryLines: string[] = []