diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 4c0931b..bbc75ad 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -717,7 +717,7 @@ export async function installPreset( return anyAdded; } -async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise { +export async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise { if (options.list) { await runList(ruleArg); return; diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 92c0eb8..8b9343b 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -154,7 +154,7 @@ export async function executePipeline(options: PipelineOptions): Promise { +export async function runCompile(options: CompileOptions): Promise { const cwd = process.cwd(); if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index e6b8797..b2c0092 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -257,7 +257,7 @@ export async function checkHashSync(cwd: string, rules: Rule[]): Promise { +export async function runDoctor(): Promise { const cwd = process.cwd(); const startTime = performance.now(); const results: CheckResult[] = []; diff --git a/packages/cli/src/commands/menu.ts b/packages/cli/src/commands/menu.ts new file mode 100644 index 0000000..9fa6361 --- /dev/null +++ b/packages/cli/src/commands/menu.ts @@ -0,0 +1,82 @@ +import { select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import type { Command } from 'commander'; +import { runAdd } from './add.js'; +import { runRemove } from './remove.js'; +import { runDoctor } from './doctor.js'; +import { runCompile } from './compile.js'; + +const menuTheme = { + style: { + keysHelpTip: (keys: [string, string][]): string => + [...keys, ['Ctrl+C', 'back']] + .map(([key, action]) => `${chalk.bold(key)} ${chalk.dim(action)}`) + .join(chalk.dim(' • ')), + }, +} as const; + +const MENU_CHOICES = { + ADD: 'add', + COMPILE: 'compile', + DOCTOR: 'doctor', + REMOVE: 'remove', + EXIT: 'exit', +} as const; + +type MenuChoice = (typeof MENU_CHOICES)[keyof typeof MENU_CHOICES]; + +export async function runMainMenu(command: Command): Promise { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + command.help(); + return; + } + + while (true) { + let choice: MenuChoice; + try { + choice = await select({ + message: 'What do you want to do?', + theme: menuTheme, + choices: [ + { name: 'Add rules or assets', value: MENU_CHOICES.ADD }, + { name: 'Compile for all editors', value: MENU_CHOICES.COMPILE }, + { name: 'Check project status', value: MENU_CHOICES.DOCTOR }, + { name: 'Remove something', value: MENU_CHOICES.REMOVE }, + { name: 'Exit', value: MENU_CHOICES.EXIT }, + ], + }); + } catch (err) { + if (err instanceof Error && err.name === 'ExitPromptError') { + process.exit(0); + } + throw err; + } + + if (choice === MENU_CHOICES.EXIT) { + process.exit(0); + } + + try { + switch (choice) { + case MENU_CHOICES.ADD: + await runAdd(undefined, {}); + break; + case MENU_CHOICES.COMPILE: + await runCompile({ verbose: false, dryRun: false }); + break; + case MENU_CHOICES.DOCTOR: + await runDoctor(); + break; + case MENU_CHOICES.REMOVE: + await runRemove(undefined); + break; + } + } catch (err) { + if (err instanceof Error && err.name === 'ExitPromptError') { + // Ctrl+C inside a subcommand — return to main menu + } else { + throw err; + } + } + } +} diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index a0a891d..00df3eb 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -51,7 +51,7 @@ async function removeRule(cwd: string, path: string): Promise { return true; } -async function runRemove(ruleArg: string | undefined): Promise { +export async function runRemove(ruleArg: string | undefined): Promise { const cwd = process.cwd(); if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f688cdd..dda7ddd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,7 @@ import { registerRemoveCommand } from './commands/remove.js'; import { registerListCommand } from './commands/list.js'; import { registerExplainCommand } from './commands/explain.js'; import { registerWatchCommand } from './commands/watch.js'; +import { runMainMenu } from './commands/menu.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; @@ -28,6 +29,10 @@ registerListCommand(program); registerExplainCommand(program); registerWatchCommand(program); +program.action(async (_, command: Command) => { + await runMainMenu(command); +}); + program.parse(); export { program }; diff --git a/packages/cli/tests/commands/menu.test.ts b/packages/cli/tests/commands/menu.test.ts new file mode 100644 index 0000000..c37d89e --- /dev/null +++ b/packages/cli/tests/commands/menu.test.ts @@ -0,0 +1,74 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { runMainMenu } from '../../src/commands/menu.js'; + +function makeMockCommand(): { helpCalled: boolean; help: () => void } { + const mock = { + helpCalled: false, + help(): void { + this.helpCalled = true; + }, + }; + return mock; +} + +describe('runMainMenu — TTY guard', () => { + let originalStdoutIsTTY: boolean | undefined; + let originalStdinIsTTY: boolean | undefined; + + beforeEach(() => { + originalStdoutIsTTY = process.stdout.isTTY; + originalStdinIsTTY = process.stdin.isTTY; + }); + + afterEach(() => { + // Restore originals (may be undefined in non-TTY test environments) + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + writable: true, + configurable: true, + }); + }); + + it('calls command.help() when stdout is not a TTY', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + + const mockCommand = makeMockCommand(); + // Cast to unknown then to the minimal Command interface required + await runMainMenu(mockCommand as unknown as import('commander').Command); + + assert.equal(mockCommand.helpCalled, true); + }); + + it('calls command.help() when stdin is not a TTY even if stdout is', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + + const mockCommand = makeMockCommand(); + await runMainMenu(mockCommand as unknown as import('commander').Command); + + assert.equal(mockCommand.helpCalled, true); + }); +}); diff --git a/packages/cli/tests/e2e/cli.test.ts b/packages/cli/tests/e2e/cli.test.ts index bbaba68..155796e 100644 --- a/packages/cli/tests/e2e/cli.test.ts +++ b/packages/cli/tests/e2e/cli.test.ts @@ -182,6 +182,14 @@ rules: assert.ok(result.stdout.includes('config.yml is valid')); }); + it('devw with no args in non-TTY exits 0 and prints usage', async () => { + // execFile runs in non-TTY by default — menu should display help instead of prompting + const result = await run([], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes('Usage:')); + }); + it('add without args and non-TTY exits with error', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); // execFile runs in non-TTY mode by default