gsdd-cli 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/gsdd.mjs CHANGED
@@ -24,51 +24,61 @@ const __dirname = dirname(__filename);
24
24
  const DISTILLED_DIR = join(__dirname, '..', 'distilled');
25
25
  const AGENTS_DIR = join(__dirname, '..', 'agents');
26
26
  const CWD = process.cwd();
27
- const PLANNING_DIR = join(CWD, '.planning');
28
27
  const IS_MAIN = process.argv[1]
29
28
  ? realpathSync(process.argv[1]) === realpathSync(__filename)
30
29
  : false;
31
30
 
32
31
  const [,, command, ...args] = process.argv;
33
32
 
33
+ function defineWorkflow({ mutatesArtifacts = true, ...workflow }) {
34
+ return {
35
+ ...workflow,
36
+ mutatesArtifacts,
37
+ agent: mutatesArtifacts ? 'Code' : 'Plan',
38
+ opencodeType: mutatesArtifacts ? 'edit' : 'plan',
39
+ };
40
+ }
41
+
34
42
  const WORKFLOWS = [
35
- { name: 'gsdd-new-project', workflow: 'new-project.md', description: 'New project - questioning, codebase audit, research, spec, roadmap', agent: 'Plan', opencodeType: 'plan' },
36
- { name: 'gsdd-map-codebase', workflow: 'map-codebase.md', description: 'Map or refresh codebase - 4 parallel mappers, staleness check, secrets scan', agent: 'Plan', opencodeType: 'plan' },
37
- { name: 'gsdd-plan', workflow: 'plan.md', description: 'Plan a phase - research check, backward planning, task creation', agent: 'Plan', opencodeType: 'plan' },
38
- { name: 'gsdd-execute', workflow: 'execute.md', description: 'Execute a phase plan - implement tasks, verify changes, follow repo git conventions', agent: 'Code', opencodeType: 'edit' },
39
- { name: 'gsdd-verify', workflow: 'verify.md', description: 'Verify a completed phase - 3-level checks, anti-pattern scan', agent: 'Plan', opencodeType: 'plan' },
40
- { name: 'gsdd-audit-milestone', workflow: 'audit-milestone.md', description: 'Audit a completed milestone - cross-phase integration, requirements coverage, E2E flows', agent: 'Plan', opencodeType: 'plan' },
41
- { name: 'gsdd-quick', workflow: 'quick.md', description: 'Quick task - plan and execute a sub-hour task outside the phase cycle', agent: 'Code', opencodeType: 'edit' },
42
- { name: 'gsdd-pause', workflow: 'pause.md', description: 'Pause work - save session context for seamless resumption', agent: 'Plan', opencodeType: 'plan' },
43
- { name: 'gsdd-resume', workflow: 'resume.md', description: 'Resume work - restore context and route to next action', agent: 'Plan', opencodeType: 'plan' },
44
- { name: 'gsdd-progress', workflow: 'progress.md', description: 'Check progress - show project status and route to next action', agent: 'Plan', opencodeType: 'plan' },
43
+ defineWorkflow({ name: 'gsdd-new-project', workflow: 'new-project.md', description: 'New project - questioning, codebase audit, research, spec, roadmap' }),
44
+ defineWorkflow({ name: 'gsdd-map-codebase', workflow: 'map-codebase.md', description: 'Map or refresh codebase - 4 parallel mappers, staleness check, secrets scan' }),
45
+ defineWorkflow({ name: 'gsdd-plan', workflow: 'plan.md', description: 'Plan a phase - research check, backward planning, task creation' }),
46
+ defineWorkflow({ name: 'gsdd-execute', workflow: 'execute.md', description: 'Execute a phase plan - implement tasks, verify changes, follow repo git conventions' }),
47
+ defineWorkflow({ name: 'gsdd-verify', workflow: 'verify.md', description: 'Verify a completed phase - 3-level checks, anti-pattern scan' }),
48
+ defineWorkflow({ name: 'gsdd-audit-milestone', workflow: 'audit-milestone.md', description: 'Audit a completed milestone - cross-phase integration, requirements coverage, E2E flows' }),
49
+ defineWorkflow({ name: 'gsdd-quick', workflow: 'quick.md', description: 'Quick task - plan and execute a sub-hour task outside the phase cycle' }),
50
+ defineWorkflow({ name: 'gsdd-pause', workflow: 'pause.md', description: 'Pause work - save session context for seamless resumption' }),
51
+ defineWorkflow({ name: 'gsdd-resume', workflow: 'resume.md', description: 'Resume work - restore context and route to next action' }),
52
+ defineWorkflow({ name: 'gsdd-progress', workflow: 'progress.md', description: 'Check progress - show project status and route to next action', mutatesArtifacts: false }),
45
53
  ];
46
54
 
47
55
  const FRAMEWORK_VERSION = 'v1.2';
48
56
 
49
- const ADAPTERS = createAdapterRegistry({
50
- cwd: CWD,
51
- workflows: WORKFLOWS,
52
- renderAgentsBoundedBlock,
53
- renderAgentsFileContent,
54
- renderOpenCodeCommandContent,
55
- renderSkillContent,
56
- upsertBoundedBlock,
57
- getDelegateContent,
58
- loadProjectModelConfig,
59
- getRuntimeModelOverride,
60
- resolveRuntimeAgentModel,
61
- });
57
+ function createCliContext(cwd = process.cwd()) {
58
+ return {
59
+ cwd,
60
+ planningDir: join(cwd, '.planning'),
61
+ distilledDir: DISTILLED_DIR,
62
+ agentsDir: AGENTS_DIR,
63
+ workflows: WORKFLOWS,
64
+ frameworkVersion: FRAMEWORK_VERSION,
65
+ adapters: createAdapterRegistry({
66
+ cwd,
67
+ workflows: WORKFLOWS,
68
+ renderAgentsBoundedBlock,
69
+ renderAgentsFileContent,
70
+ renderOpenCodeCommandContent,
71
+ renderSkillContent,
72
+ upsertBoundedBlock,
73
+ getDelegateContent,
74
+ loadProjectModelConfig,
75
+ getRuntimeModelOverride,
76
+ resolveRuntimeAgentModel,
77
+ }),
78
+ };
79
+ }
62
80
 
63
- const INIT_CONTEXT = {
64
- cwd: CWD,
65
- planningDir: PLANNING_DIR,
66
- distilledDir: DISTILLED_DIR,
67
- agentsDir: AGENTS_DIR,
68
- workflows: WORKFLOWS,
69
- frameworkVersion: FRAMEWORK_VERSION,
70
- adapters: ADAPTERS,
71
- };
81
+ const INIT_CONTEXT = createCliContext(CWD);
72
82
 
73
83
  const cmdInit = createCmdInit(INIT_CONTEXT);
74
84
  const cmdUpdate = createCmdUpdate(INIT_CONTEXT);
@@ -99,4 +109,4 @@ if (IS_MAIN) {
99
109
  await runCli();
100
110
  }
101
111
 
102
- export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFindPhase, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION };
112
+ export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFindPhase, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
@@ -0,0 +1,220 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'fs';
2
+ import { join, isAbsolute } from 'path';
3
+ import { renderSkillContent } from './rendering.mjs';
4
+ import { buildManifest, writeManifest } from './manifest.mjs';
5
+ import { parseFlagValue, parseToolsFlag, parseAutoFlag } from './cli-utils.mjs';
6
+ import { buildDefaultConfig } from './models.mjs';
7
+ import { installProjectTemplates, refreshTemplates } from './templates.mjs';
8
+ import {
9
+ detectPlatforms,
10
+ getAdaptersToUpdate,
11
+ getPostInitRoutingLines,
12
+ normalizeRequestedTools,
13
+ resolveAdapters,
14
+ resolveInteractiveInitSession,
15
+ } from './init-runtime.mjs';
16
+ import { createInitPromptApi } from './init-prompts.mjs';
17
+
18
+ export function createCmdInit(ctx) {
19
+ return async function cmdInit(...initArgs) {
20
+ console.log('gsdd init - setting up SDD workflow\n');
21
+
22
+ const isAuto = parseAutoFlag(initArgs);
23
+ const toolsFlag = parseFlagValue(initArgs, '--tools');
24
+ const briefFlag = parseFlagValue(initArgs, '--brief');
25
+ let briefSource = null;
26
+
27
+ if (toolsFlag.invalid) {
28
+ console.error('ERROR: --tools requires a value. Example: gsdd init --tools claude');
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+
33
+ if (briefFlag.invalid) {
34
+ console.error('ERROR: --brief requires a file path. Example: gsdd init --brief project-idea.md');
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+
39
+ if (briefFlag.value) {
40
+ briefSource = isAbsolute(briefFlag.value) ? briefFlag.value : join(ctx.cwd, briefFlag.value);
41
+ if (!existsSync(briefSource)) {
42
+ console.error(`ERROR: Brief file not found: ${briefFlag.value}`);
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ }
47
+
48
+ const parsedTools = parseToolsFlag(initArgs);
49
+ if (isAuto && parsedTools.length === 0) {
50
+ console.error('ERROR: --auto requires --tools <platform>. Example: gsdd init --auto --tools claude');
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+
55
+ const promptApi = ctx.initPromptApi || createInitPromptApi();
56
+ const interactiveSession = await resolveInteractiveInitSession({
57
+ ctx,
58
+ promptApi,
59
+ parsedTools,
60
+ isAuto,
61
+ });
62
+
63
+ const existed = existsSync(ctx.planningDir);
64
+ mkdirSync(join(ctx.planningDir, 'phases'), { recursive: true });
65
+ mkdirSync(join(ctx.planningDir, 'research'), { recursive: true });
66
+ console.log(existed
67
+ ? ' - .planning/ already exists (ensured subdirectories)'
68
+ : ' - created .planning/ directory structure');
69
+
70
+ installProjectTemplates(ctx);
71
+ await ensureConfig({
72
+ cwd: ctx.cwd,
73
+ planningDir: ctx.planningDir,
74
+ isAuto,
75
+ promptApi,
76
+ preselectedConfig: interactiveSession.config,
77
+ });
78
+
79
+ if (briefSource) {
80
+ cpSync(briefSource, join(ctx.planningDir, 'PROJECT_BRIEF.md'));
81
+ console.log(' - copied project brief to .planning/PROJECT_BRIEF.md');
82
+ }
83
+
84
+ generateOpenStandardSkills(ctx.cwd, ctx.workflows);
85
+ console.log(' - generated open-standard skills (.agents/skills/gsdd-*)');
86
+
87
+ for (const adapter of resolveAdapters(ctx.adapters, interactiveSession.adapterTargets)) {
88
+ adapter.generate();
89
+ console.log(` - ${adapter.summary('generated')}`);
90
+ }
91
+
92
+ const manifest = buildManifest({ planningDir: ctx.planningDir, frameworkVersion: ctx.frameworkVersion });
93
+ writeManifest(ctx.planningDir, manifest);
94
+ console.log(' - wrote generation manifest');
95
+
96
+ console.log('\nSDD initialized.');
97
+ console.log('Next: run the new-project workflow to produce SPEC.md and ROADMAP.md:\n');
98
+ printPostInitRouting(interactiveSession.selectedRuntimes);
99
+ };
100
+ }
101
+
102
+ export function createCmdUpdate(ctx) {
103
+ return function cmdUpdate(...updateArgs) {
104
+ const isDry = updateArgs.includes('--dry');
105
+ const doTemplates = updateArgs.includes('--templates');
106
+
107
+ console.log(`gsdd update - regenerating adapter files${isDry ? ' (dry run)' : ''}\n`);
108
+
109
+ const parsedTools = parseToolsFlag(updateArgs);
110
+ const requested = normalizeRequestedTools(parsedTools);
111
+ const platforms = parsedTools.length > 0 ? requested.adapterTargets : detectPlatforms(ctx.adapters);
112
+
113
+ let updated = false;
114
+
115
+ if (doTemplates) {
116
+ refreshTemplates({ ...ctx, isDry });
117
+ updated = true;
118
+ }
119
+
120
+ if (platforms.length > 0 || existsSync(join(ctx.cwd, '.agents', 'skills'))) {
121
+ if (isDry) {
122
+ console.log(' - would update open-standard skills (.agents/skills/gsdd-*)');
123
+ } else {
124
+ generateOpenStandardSkills(ctx.cwd, ctx.workflows);
125
+ console.log(' - updated open-standard skills (.agents/skills/gsdd-*)');
126
+ }
127
+ updated = true;
128
+ }
129
+
130
+ for (const adapter of getAdaptersToUpdate(ctx.adapters, platforms)) {
131
+ if (isDry) {
132
+ console.log(` - would update ${adapter.name} adapter`);
133
+ } else {
134
+ adapter.generate();
135
+ console.log(` - ${adapter.summary('updated')}`);
136
+ }
137
+ updated = true;
138
+ }
139
+
140
+ if (!updated) {
141
+ console.log(' - no adapters found to update (run `gsdd init` first)');
142
+ } else if (isDry) {
143
+ console.log('\nDry run complete. No files were written.\n');
144
+ } else {
145
+ if (doTemplates && existsSync(ctx.planningDir)) {
146
+ const manifest = buildManifest({ planningDir: ctx.planningDir, frameworkVersion: ctx.frameworkVersion });
147
+ writeManifest(ctx.planningDir, manifest);
148
+ console.log(' - updated generation manifest');
149
+ }
150
+ console.log('\nAdapters updated.\n');
151
+ }
152
+ };
153
+ }
154
+
155
+ function generateOpenStandardSkills(cwd, workflows) {
156
+ for (const workflow of workflows) {
157
+ const dir = join(cwd, '.agents', 'skills', workflow.name);
158
+ mkdirSync(dir, { recursive: true });
159
+ writeFileSync(join(dir, 'SKILL.md'), renderSkillContent(workflow));
160
+ }
161
+ }
162
+
163
+ async function ensureConfig({ cwd, planningDir, isAuto, promptApi, preselectedConfig = null }) {
164
+ const configFile = join(planningDir, 'config.json');
165
+ if (existsSync(configFile)) {
166
+ console.log(' - .planning/config.json already exists');
167
+ return;
168
+ }
169
+
170
+ if (preselectedConfig) {
171
+ writeFileSync(configFile, JSON.stringify(preselectedConfig, null, 2));
172
+ console.log(' - saved .planning/config.json (guided wizard)\n');
173
+ if (!preselectedConfig.commitDocs) ensureGitignoreEntry(cwd);
174
+ return;
175
+ }
176
+
177
+ if (isAuto) {
178
+ console.log(' - auto mode: writing config.json with defaults');
179
+ writeFileSync(configFile, JSON.stringify(buildDefaultConfig({ autoAdvance: true }), null, 2));
180
+ console.log(' - saved .planning/config.json (auto mode - autoAdvance enabled)\n');
181
+ return;
182
+ }
183
+
184
+ if (!process.stdin.isTTY) {
185
+ console.log(' - non-interactive mode detected: writing config.json with defaults');
186
+ writeFileSync(configFile, JSON.stringify(buildDefaultConfig(), null, 2));
187
+ console.log(' - saved .planning/config.json (defaults - re-run gsdd init in a terminal to customize)\n');
188
+ return;
189
+ }
190
+
191
+ const config = await promptApi.promptForConfig(cwd);
192
+ writeFileSync(configFile, JSON.stringify(config, null, 2));
193
+ console.log(' - saved .planning/config.json (guided wizard)\n');
194
+
195
+ if (!config.commitDocs) ensureGitignoreEntry(cwd);
196
+ }
197
+
198
+ function ensureGitignoreEntry(cwd) {
199
+ const gitignorePath = join(cwd, '.gitignore');
200
+ const ignoreEntry = '\n# GSDD planning docs (local only)\n.planning/\n';
201
+
202
+ if (existsSync(gitignorePath)) {
203
+ const existing = readFileSync(gitignorePath, 'utf-8');
204
+ if (!existing.includes('.planning/')) {
205
+ writeFileSync(gitignorePath, existing + ignoreEntry);
206
+ console.log(' - added .planning/ to .gitignore');
207
+ }
208
+ return;
209
+ }
210
+
211
+ writeFileSync(gitignorePath, ignoreEntry.trimStart());
212
+ console.log(' - created .gitignore with .planning/ entry');
213
+ }
214
+
215
+ function printPostInitRouting(selectedRuntimes) {
216
+ for (const line of getPostInitRoutingLines(selectedRuntimes)) {
217
+ console.log(line);
218
+ }
219
+ console.log('');
220
+ }
@@ -0,0 +1,308 @@
1
+ import * as readline from 'readline';
2
+ import { DEFAULT_GIT_PROTOCOL, normalizeModelProfile } from './models.mjs';
3
+ import { buildRuntimeChoices, INIT_VERSION, resolveWizardAdapterTargets } from './init-runtime.mjs';
4
+
5
+ const ANSI = {
6
+ reset: '\x1b[0m',
7
+ dim: '\x1b[2m',
8
+ bold: '\x1b[1m',
9
+ cyan: '\x1b[36m',
10
+ green: '\x1b[32m',
11
+ };
12
+
13
+ export function createInitPromptApi({ input = process.stdin, output = process.stdout } = {}) {
14
+ return {
15
+ async runInitWizard({ cwd, adapters }) {
16
+ output.write(`${ANSI.bold}${ANSI.cyan}GSDD Install Wizard${ANSI.reset}\n`);
17
+ output.write(`${ANSI.dim}Portable skills are always installed. Select the runtimes you want GSDD to feel native in.${ANSI.reset}\n\n`);
18
+
19
+ const runtimeChoices = buildRuntimeChoices(adapters);
20
+ const selectedRuntimes = await promptMultiSelect({
21
+ input,
22
+ output,
23
+ title: 'Step 1 of 3 - Select runtimes',
24
+ hint: 'Space toggles, Enter confirms.',
25
+ choices: runtimeChoices,
26
+ });
27
+ const installGovernance = await promptConfirm({
28
+ input,
29
+ output,
30
+ title: 'Step 2 of 3 - Repo-wide AGENTS.md governance',
31
+ prompt: 'Install repo-wide AGENTS.md rules?',
32
+ defaultValue: false,
33
+ details: [
34
+ 'Why care: consistent behavioral discipline across sessions and mixed-runtime teams.',
35
+ 'Why skip it: it writes to the repo root, and your selected runtimes already discover .agents/skills/ natively.',
36
+ ],
37
+ });
38
+ const config = await promptForConfig(cwd, { input, output });
39
+ return {
40
+ selectedRuntimes,
41
+ adapterTargets: resolveWizardAdapterTargets(selectedRuntimes, installGovernance),
42
+ config,
43
+ };
44
+ },
45
+ promptForConfig(cwd) {
46
+ return promptForConfig(cwd, { input, output });
47
+ },
48
+ };
49
+ }
50
+
51
+ export async function promptForConfig(cwd, { input = process.stdin, output = process.stdout } = {}) {
52
+ output.write(`\n${ANSI.bold}Step 3 of 3 - Configure planning defaults${ANSI.reset}\n`);
53
+
54
+ const researchDepth = await promptSingleSelect({
55
+ input,
56
+ output,
57
+ title: 'Research depth',
58
+ choices: [
59
+ { value: 'balanced', label: 'balanced', description: 'SOTA research per phase (recommended)' },
60
+ { value: 'fast', label: 'fast', description: 'Skip deeper domain research and plan from current context' },
61
+ { value: 'deep', label: 'deep', description: 'Exhaustive research sweeps and parallel researchers' },
62
+ ],
63
+ defaultIndex: 0,
64
+ });
65
+ const parallelization = await promptSingleSelect({
66
+ input,
67
+ output,
68
+ title: 'Parallelization',
69
+ choices: yesNoChoices('Run independent agents in parallel?', true),
70
+ defaultIndex: 0,
71
+ });
72
+ const commitDocs = await promptSingleSelect({
73
+ input,
74
+ output,
75
+ title: 'Planning docs in git',
76
+ choices: yesNoChoices('Track .planning/ in git?', true),
77
+ defaultIndex: 0,
78
+ });
79
+ const modelProfile = await promptSingleSelect({
80
+ input,
81
+ output,
82
+ title: 'Model profile',
83
+ choices: [
84
+ { value: 'balanced', label: 'balanced', description: 'Capable default for most agents (recommended)' },
85
+ { value: 'quality', label: 'quality', description: 'Most capable model for research and roadmap work' },
86
+ { value: 'budget', label: 'budget', description: 'Cheapest and fastest model profile' },
87
+ ],
88
+ defaultIndex: 0,
89
+ });
90
+ const workflowResearch = await promptSingleSelect({
91
+ input,
92
+ output,
93
+ title: 'Workflow research',
94
+ choices: yesNoChoices('Research before planning each phase?', true),
95
+ defaultIndex: 0,
96
+ });
97
+ const workflowDiscuss = await promptSingleSelect({
98
+ input,
99
+ output,
100
+ title: 'Approach discussion',
101
+ choices: yesNoChoices('Explore approaches with the user before planning?', false),
102
+ defaultIndex: 0,
103
+ });
104
+ const workflowPlanCheck = await promptSingleSelect({
105
+ input,
106
+ output,
107
+ title: 'Plan checking',
108
+ choices: yesNoChoices('Run the fresh-context plan checker before execution?', true),
109
+ defaultIndex: 0,
110
+ });
111
+ const workflowVerifier = await promptSingleSelect({
112
+ input,
113
+ output,
114
+ title: 'Phase verification',
115
+ choices: yesNoChoices('Run phase verification after execution?', true),
116
+ defaultIndex: 0,
117
+ });
118
+
119
+ const gitProtocol = await promptGitProtocol(cwd, { input, output });
120
+
121
+ return {
122
+ researchDepth,
123
+ parallelization,
124
+ commitDocs,
125
+ modelProfile: normalizeModelProfile(modelProfile),
126
+ workflow: {
127
+ research: workflowResearch,
128
+ discuss: workflowDiscuss,
129
+ planCheck: workflowPlanCheck,
130
+ verifier: workflowVerifier,
131
+ },
132
+ gitProtocol,
133
+ initVersion: INIT_VERSION,
134
+ };
135
+ }
136
+
137
+ export async function promptGitProtocol(cwd, { input = process.stdin, output = process.stdout } = {}) {
138
+ if (typeof input.resume === 'function') input.resume();
139
+ output.write(`\n${ANSI.bold}Version control guidance${ANSI.reset}\n`);
140
+ output.write(`${ANSI.dim}This is advisory only. Repo or user conventions still win.${ANSI.reset}\n`);
141
+ const rl = readline.createInterface({ input, output });
142
+ const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
143
+
144
+ try {
145
+ let branch = await askQuestion(' Branching guidance (Enter for default): ');
146
+ branch = branch.trim() || DEFAULT_GIT_PROTOCOL.branch;
147
+
148
+ let commit = await askQuestion(' Commit guidance (Enter for default): ');
149
+ commit = commit.trim() || DEFAULT_GIT_PROTOCOL.commit;
150
+
151
+ let pr = await askQuestion(' PR guidance (Enter for default): ');
152
+ pr = pr.trim() || DEFAULT_GIT_PROTOCOL.pr;
153
+
154
+ return { branch, commit, pr };
155
+ } finally {
156
+ rl.close();
157
+ }
158
+ }
159
+
160
+ export function yesNoChoices(prompt, defaultYes) {
161
+ return [
162
+ { value: true, label: 'yes', description: `${prompt} Yes.` },
163
+ { value: false, label: 'no', description: `${prompt} No.` },
164
+ ].sort((a, b) => {
165
+ if (defaultYes) return a.value === true ? -1 : 1;
166
+ return a.value === false ? -1 : 1;
167
+ });
168
+ }
169
+
170
+ export async function promptConfirm({ input, output, title, prompt, defaultValue, details = [] }) {
171
+ if (typeof input.resume === 'function') input.resume();
172
+ output.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
173
+ for (const detail of details) {
174
+ output.write(` ${ANSI.dim}${detail}${ANSI.reset}\n`);
175
+ }
176
+ const rl = readline.createInterface({ input, output });
177
+ const suffix = defaultValue ? '[Y/n]' : '[y/N]';
178
+ try {
179
+ const answer = await new Promise((resolve) => rl.question(` ${prompt} ${suffix}: `, resolve));
180
+ const normalized = answer.trim().toLowerCase();
181
+ if (!normalized) return defaultValue;
182
+ return normalized === 'y' || normalized === 'yes';
183
+ } finally {
184
+ rl.close();
185
+ }
186
+ }
187
+
188
+ export async function promptSingleSelect({ input, output, title, choices, defaultIndex = 0 }) {
189
+ const selected = await promptChoiceList({
190
+ input,
191
+ output,
192
+ title,
193
+ choices: choices.map((choice, index) => ({
194
+ ...choice,
195
+ selected: index === defaultIndex,
196
+ detected: false,
197
+ })),
198
+ multi: false,
199
+ });
200
+ return selected[0];
201
+ }
202
+
203
+ export async function promptMultiSelect({ input, output, title, hint, choices }) {
204
+ return promptChoiceList({ input, output, title, hint, choices, multi: true });
205
+ }
206
+
207
+ export async function promptChoiceList({ input, output, title, hint = 'Use arrows to move. Enter confirms.', choices, multi }) {
208
+ if (typeof input.resume === 'function') input.resume();
209
+ readline.emitKeypressEvents(input);
210
+ const previousRawMode = typeof input.isRaw === 'boolean' ? input.isRaw : false;
211
+ if (typeof input.setRawMode === 'function') input.setRawMode(true);
212
+
213
+ let cursor = Math.max(0, choices.findIndex((choice) => choice.selected));
214
+ let renderedLines = 0;
215
+
216
+ const selectCursor = () => {
217
+ if (multi) return;
218
+ for (const choice of choices) choice.selected = false;
219
+ choices[cursor].selected = true;
220
+ };
221
+
222
+ const measureLines = (text) => {
223
+ const columns = Math.max(20, Number(output.columns) || 80);
224
+ const visible = stripAnsi(text);
225
+ return visible.split('\n').reduce((total, line) => {
226
+ const width = Math.max(1, line.length);
227
+ return total + Math.ceil(width / columns);
228
+ }, 0);
229
+ };
230
+
231
+ const render = () => {
232
+ if (renderedLines > 0) {
233
+ readline.moveCursor(output, 0, -renderedLines);
234
+ }
235
+ readline.cursorTo(output, 0);
236
+ readline.clearScreenDown(output);
237
+ const lines = [`${ANSI.bold}${title}${ANSI.reset}`];
238
+ if (hint) lines.push(`${ANSI.dim}${hint}${ANSI.reset}`);
239
+ lines.push('');
240
+ for (let i = 0; i < choices.length; i++) {
241
+ const choice = choices[i];
242
+ const pointer = i === cursor ? `${ANSI.green}>${ANSI.reset}` : ' ';
243
+ const mark = choice.selected ? `${ANSI.green}[x]${ANSI.reset}` : '[ ]';
244
+ const detected = choice.detected ? ` ${ANSI.dim}(detected)${ANSI.reset}` : '';
245
+ lines.push(`${pointer} ${mark} ${choice.label}${detected}`);
246
+ lines.push(` ${ANSI.dim}${choice.description}${ANSI.reset}`);
247
+ }
248
+
249
+ renderedLines = 0;
250
+ for (const line of lines) {
251
+ output.write(`${line}\n`);
252
+ renderedLines += measureLines(line);
253
+ }
254
+ };
255
+
256
+ render();
257
+
258
+ const values = await new Promise((resolve, reject) => {
259
+ const cleanup = () => {
260
+ input.off('keypress', onKeypress);
261
+ if (typeof input.setRawMode === 'function') input.setRawMode(previousRawMode);
262
+ };
263
+
264
+ const onKeypress = (_, key = {}) => {
265
+ if (key.ctrl && key.name === 'c') {
266
+ cleanup();
267
+ reject(new Error('Prompt cancelled by user'));
268
+ return;
269
+ }
270
+ if (key.name === 'up') {
271
+ cursor = cursor === 0 ? choices.length - 1 : cursor - 1;
272
+ selectCursor();
273
+ render();
274
+ return;
275
+ }
276
+ if (key.name === 'down') {
277
+ cursor = cursor === choices.length - 1 ? 0 : cursor + 1;
278
+ selectCursor();
279
+ render();
280
+ return;
281
+ }
282
+ if (key.name === 'space') {
283
+ if (multi) {
284
+ choices[cursor].selected = !choices[cursor].selected;
285
+ } else {
286
+ for (const choice of choices) choice.selected = false;
287
+ choices[cursor].selected = true;
288
+ }
289
+ render();
290
+ return;
291
+ }
292
+ if (key.name === 'return') {
293
+ selectCursor();
294
+ cleanup();
295
+ resolve(choices.filter((choice) => choice.selected).map((choice) => choice.value ?? choice.id));
296
+ }
297
+ };
298
+
299
+ input.on('keypress', onKeypress);
300
+ });
301
+
302
+ output.write('\n');
303
+ return values;
304
+ }
305
+
306
+ export function stripAnsi(text) {
307
+ return String(text).replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
308
+ }