Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 03ea32369a fix(cli): prevent hang in pre-commit hooks by using dynamic imports
Fixes #367

The CLI was hanging when run as a pre-commit hook because @inquirer/prompts
was statically imported at module load time. Even when prompts were never
called (e.g., `openspec validate --specs --no-interactive`), the import
itself could set up stdin references that prevented clean process exit
when stdin was piped.

Changes:
- Convert all static `@inquirer/prompts` imports to dynamic imports
- Dynamically import `InitCommand` (which uses `@inquirer/core`)
- Update `isInteractive()` to accept options object with both
  `noInteractive` and Commander's negated `interactive` property
- Handle empty validation queue with proper exit code

Now when running in non-interactive mode, the inquirer modules are never
loaded, allowing the process to exit cleanly after completion.
2025-12-21 17:48:32 +11:00
8 changed files with 61 additions and 22 deletions
+2 -2
View File
@@ -3,7 +3,6 @@ import { createRequire } from 'module';
import ora from 'ora';
import path from 'path';
import { promises as fs } from 'fs';
import { InitCommand } from '../core/init.js';
import { AI_TOOLS } from '../core/config.js';
import { UpdateCommand } from '../core/update.js';
import { ListCommand } from '../core/list.js';
@@ -30,7 +29,7 @@ program.option('--no-color', 'Disable color output');
// Apply global flags before any command runs
program.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
if (opts.noColor) {
if (opts.color === false) {
process.env.NO_COLOR = '1';
}
});
@@ -63,6 +62,7 @@ program
}
}
const { InitCommand } = await import('../core/init.js');
const initCommand = new InitCommand({
tools: options?.tools,
});
+4 -3
View File
@@ -1,6 +1,5 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select } from '@inquirer/prompts';
import { JsonConverter } from '../core/converters/json-converter.js';
import { Validator } from '../core/validation/validator.js';
import { ChangeParser } from '../core/parsers/change-parser.js';
@@ -30,9 +29,10 @@ export class ChangeCommand {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const changes = await this.getActiveChanges(changesPath);
if (canPrompt && changes.length > 0) {
const { select } = await import('@inquirer/prompts');
const selected = await select({
message: 'Select a change to show',
choices: changes.map(id => ({ name: id, value: id })),
@@ -186,9 +186,10 @@ export class ChangeCommand {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const changes = await getActiveChangeIds();
if (canPrompt && changes.length > 0) {
const { select } = await import('@inquirer/prompts');
const selected = await select({
message: 'Select a change to validate',
choices: changes.map(id => ({ name: id, value: id })),
+1 -1
View File
@@ -1,5 +1,4 @@
import ora from 'ora';
import { confirm } from '@inquirer/prompts';
import { CompletionFactory } from '../core/completions/factory.js';
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
@@ -179,6 +178,7 @@ export class CompletionCommand {
// Prompt for confirmation unless --yes flag is provided
if (!skipConfirmation) {
const { confirm } = await import('@inquirer/prompts');
const confirmed = await confirm({
message: 'Remove OpenSpec configuration from ~/.zshrc?',
default: false,
+3 -4
View File
@@ -1,4 +1,3 @@
import { select } from '@inquirer/prompts';
import path from 'path';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
@@ -13,11 +12,12 @@ const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
export class ShowCommand {
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
const interactive = isInteractive(options);
const typeOverride = this.normalizeType(options.type);
if (!itemName) {
if (interactive) {
const { select } = await import('@inquirer/prompts');
const type = await select<ItemType>({
message: 'What would you like to show?',
choices: [
@@ -44,6 +44,7 @@ export class ShowCommand {
}
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
const { select } = await import('@inquirer/prompts');
if (type === 'change') {
const changes = await getActiveChangeIds();
if (changes.length === 0) {
@@ -135,5 +136,3 @@ export class ShowCommand {
return false;
}
}
+5 -4
View File
@@ -4,7 +4,6 @@ import { join } from 'path';
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
import { Validator } from '../core/validation/validator.js';
import type { Spec } from '../core/schemas/index.js';
import { select } from '@inquirer/prompts';
import { isInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
@@ -70,9 +69,10 @@ export class SpecCommand {
async show(specId?: string, options: ShowOptions = {}): Promise<void> {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
const { select } = await import('@inquirer/prompts');
specId = await select({
message: 'Select a spec to show',
choices: specIds.map(id => ({ name: id, value: id })),
@@ -204,9 +204,10 @@ export function registerSpecCommand(rootProgram: typeof program) {
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
try {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
const { select } = await import('@inquirer/prompts');
specId = await select({
message: 'Select a spec to validate',
choices: specIds.map(id => ({ name: id, value: id })),
@@ -247,4 +248,4 @@ export function registerSpecCommand(rootProgram: typeof program) {
});
return specCommand;
}
}
+24 -4
View File
@@ -1,4 +1,3 @@
import { select } from '@inquirer/prompts';
import ora from 'ora';
import path from 'path';
import { Validator } from '../core/validation/validator.js';
@@ -29,7 +28,7 @@ interface BulkItemResult {
export class ValidateCommand {
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
const interactive = isInteractive(options);
// Handle bulk flags first
if (options.all || options.changes || options.specs) {
@@ -64,6 +63,7 @@ export class ValidateCommand {
}
private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const { select } = await import('@inquirer/prompts');
const choice = await select({
message: 'What would you like to validate?',
choices: [
@@ -212,6 +212,28 @@ export class ValidateCommand {
});
}
if (queue.length === 0) {
spinner?.stop();
const summary = {
totals: { items: 0, passed: 0, failed: 0 },
byType: {
...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),
...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),
},
} as const;
if (opts.json) {
const out = { items: [] as BulkItemResult[], summary, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
} else {
console.log('No items found to validate.');
}
process.exitCode = 0;
return;
}
const results: BulkItemResult[] = [];
let index = 0;
let running = 0;
@@ -301,5 +323,3 @@ function getPlannedType(index: number, changeIds: string[], specIds: string[]):
if (specIndex >= 0 && specIndex < specIds.length) return 'spec';
return undefined;
}
+4 -1
View File
@@ -1,6 +1,5 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { Validator } from './validation/validator.js';
@@ -125,6 +124,7 @@ export class ArchiveCommand {
const timestamp = new Date().toISOString();
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const proceed = await confirm({
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
default: false
@@ -149,6 +149,7 @@ export class ArchiveCommand {
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
if (incompleteTasks > 0) {
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const proceed = await confirm({
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
default: false
@@ -179,6 +180,7 @@ export class ArchiveCommand {
let shouldUpdateSpecs = true;
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
shouldUpdateSpecs = await confirm({
message: 'Proceed with spec updates?',
default: true
@@ -256,6 +258,7 @@ export class ArchiveCommand {
}
private async selectChange(changesDir: string): Promise<string | null> {
const { select } = await import('@inquirer/prompts');
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
+18 -3
View File
@@ -1,7 +1,22 @@
export function isInteractive(noInteractiveFlag?: boolean): boolean {
if (noInteractiveFlag) return false;
type InteractiveOptions = {
/**
* Explicit "disable prompts" flag passed by internal callers.
*/
noInteractive?: boolean;
/**
* Commander-style negated option: `--no-interactive` sets this to false.
*/
interactive?: boolean;
};
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
if (typeof value === 'boolean') return value;
return value?.noInteractive === true || value?.interactive === false;
}
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
if (resolveNoInteractive(value)) return false;
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
return !!process.stdin.isTTY;
}