refactor(cli): slim down top-level src/ — dedup, unused fallbacks, onboarding

Pure deletion/deduplication pass on top-level src/*.ts. External behavior
unchanged; all targeted unit tests still pass.

cli.ts (−170 net lines)
  - Adopt canonical validateConfiguredModel from startup-model-validation.ts;
    delete the drifted local copy with hardcoded model fallbacks.
  - Import CliFlags + parseCliArgs from cli-web-branch.ts instead of keeping
    a second, 90%-identical parser; pass cliFlags directly into
    runWebCliBranch instead of re-parsing process.argv.
  - Extract 3 helpers for verbatim duplicates:
      * printNonTtyErrorAndExit (TTY gate, 2 call sites)
      * printExtensionErrors (extension load errors, 2 call sites)
      * reapplyValidatedModelOnFallback (post-createAgentSession fix, 2 sites)
  - Factor runHeadlessFromAuto helper shared by the `gsd auto` shorthand
    and the auto-piped-stdout redirect.
  - Collapse ensureRtkBootstrap from hand-rolled _done flag to a
    promise-memoized doRtkBootstrap.
  - Drop redundant validateConfiguredModel pre-createAgentSession calls
    (the post-createAgentSession call is the correct one per #2626).
  - Delete dead --version/-v and --help/-h fast paths (loader.ts already
    handles these before cli.ts is imported).

cli-web-branch.ts
  - Unify CliFlags with worktree, 'mcp' mode, and _selectedSessionPath.
  - Drop unused help?/version? flags (loader.ts intercepts them).

onboarding.ts
  - Add runStep<T>() helper with shared cancel/warn handling; collapse 4
    near-identical try/catch blocks around runLlmStep, runWebSearchStep,
    runRemoteQuestionsStep, runToolKeysStep.
  - Delete trivial isCancelError helper (inlined as p.isCancel).
  - Rewrite loadPico() adapter to build PicoModule from chalk so we can
    drop the redundant picocolors dependency.

package.json / package-lock.json
  - Remove picocolors direct dep (chalk remains the single color library).
This commit is contained in:
Claude 2026-04-14 01:51:22 +00:00
parent 24f51fd76b
commit 679b3177a8
6 changed files with 254 additions and 314 deletions

122
package-lock.json generated
View file

@ -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"
}

View file

@ -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",

View file

@ -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<typeof process.stderr, 'write'>
@ -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 <name> → 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)
}

View file

@ -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 <name> → 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<unknown> },
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<void> {
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<void> {
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<void> | undefined
async function doRtkBootstrap(): Promise<void> {
// 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<void> {
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 <subcommand> --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<never> {
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.

View file

@ -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 {

View file

@ -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<ClackModule> {
try {
@ -111,10 +111,23 @@ async function loadClack(): Promise<ClackModule> {
}
}
/**
* 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<PicoModule> {
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<T>(
p: ClackModule,
warnLabel: string,
fn: () => Promise<T>,
opts: { cancelMessage?: string; errorInfo?: string } = {},
): Promise<T | null | StepCancelled> {
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<void> {
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[] = []