Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .agentsmesh/lessons/lessons.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,54 @@
"t-kw-3bb206ca"
]
},
"cli-presentation-a-clack-spinner-and-an": {
"createdAt": "2026-07-09",
"evidence": [
"src/cli/command-handlers.ts",
"src/install/prompts/bulk-prompt.ts",
"src/install/prompts/modified-files-prompt.ts"
],
"rule": "A clack spinner and an interactive readline prompt cannot share a TTY: the spinner's redraw timer overwrites the prompt line, so the question is invisible and the command hangs on stdin. In src/cli/command-handlers.ts, any handler whose runX may prompt (install → broken-link/bulk prompts; uninstall → modified-files drift prompt) MUST skip the spinner when it may prompt — gate with mayPrompt(nf) (stdin AND stdout isTTY, and neither --force nor --dry-run), mirroring the init handler. The earlier \"install/uninstall are spinner-safe because runX outputs only via the renderer after the run\" claim is WRONG: it ignored the interactive prompt flow (writeBanner + deps.ask/readLine) which writes DURING the run.",
"status": "active",
"topics": [
"cli-presentation"
],
"triggers": [
"t-glob-8173701c"
]
},
"cli-presentation-all-interactive-install-uninstall-refres": {
"createdAt": "2026-07-09",
"evidence": [
"src/install/core/prompts.ts",
"src/install/prompts/prompt-io.ts"
],
"rule": "All interactive install/uninstall/refresh prompts MUST go through prompt-io.readLine (or delegate to it), never a bespoke readline.createInterface. A raw readline without a 'close' handler HANGS on EOF/Ctrl-D (the question callback never fires), and without an 'error' handler CRASHES the process on a stream error (broken pipe, SSH drop). readLine collapses both to '' (decline) — the uniform EOF contract. This is why confirm() (src/install/core/prompts.ts) was refactored to delegate to readLine. When adding a new prompt, reuse readLine and treat '' as the safe-decline default.",
"status": "active",
"topics": [
"cli-presentation"
],
"triggers": [
"t-glob-7ba39ec1",
"t-glob-dd0538cf"
]
},
"cli-presentation-when-auditing-spinner-vs-prompt": {
"createdAt": "2026-07-09",
"evidence": [
"src/cli/command-handlers.ts",
"src/install/refresh/run-refresh.ts",
"src/install/refresh/refresh-prompt.ts"
],
"rule": "When auditing spinner-vs-prompt collisions, check EVERY command handler in src/cli/command-handlers.ts that holds a ui.spinner(), not just the one reported. refresh is a THIRD collision site (besides install/uninstall): runRefresh → runConsentPrompt (run-refresh.ts) → readLine shows a [y/N/per-pack] consent prompt for drifted packs, and it was ungated. generate/import/convert are genuinely prompt-free (verified) so their spinners are safe. Gate refresh with mayPrompt(nf) too. Rule: a handler is spinner-safe ONLY if its runX has zero reachable readLine/confirm/clack prompt; trace the full call graph before trusting an ungated spinner.",
"status": "active",
"topics": [
"cli-presentation"
],
"triggers": [
"t-glob-8173701c"
]
},
"coverage-analysis-agentsmesh-coverage-thresholds-in-vitest": {
"createdAt": "2026-06-28",
"evidence": [],
Expand Down Expand Up @@ -8238,6 +8286,10 @@
"kind": "file_glob",
"pattern": "src/lessons/keyword-match.ts"
},
"t-glob-7ba39ec1": {
"kind": "file_glob",
"pattern": "src/install/prompts/prompt-io.ts"
},
"t-glob-7d39ded4": {
"kind": "file_glob",
"pattern": "src/config/core/conversions.ts"
Expand Down Expand Up @@ -8570,6 +8622,10 @@
"kind": "file_glob",
"pattern": "tests/fixtures/lessons/recurrence/**"
},
"t-glob-dd0538cf": {
"kind": "file_glob",
"pattern": "src/install/core/prompts.ts"
},
"t-glob-dd0e31f0": {
"kind": "file_glob",
"pattern": "src/targets/copilot/**"
Expand Down
7 changes: 7 additions & 0 deletions .changeset/fix-interactive-install-spinner-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'agentsmesh': patch
---

Fix interactive prompts hanging behind the status spinner. `install`, `uninstall`, and `refresh` held the spinner across the whole run, and its redraw timer overwrote the interactive prompts underneath (skill-pack select, broken-link, invalid-resource confirm, uninstall drift, and the refresh consent prompt) — leaving the command waiting on invisible input (refresh silently timed out after 5 minutes and skipped the pack). The spinner now yields the terminal to the prompt flow whenever a run may prompt (real TTY, no `--force`/`--dry-run`), mirroring `init`.

Also hardened the prompt primitives: `confirm()` now reuses the shared `readLine` helper so a closed or erroring stdin declines gracefully instead of hanging (Ctrl-D) or crashing; `readLine` resolves to empty (decline) on a stream `error` instead of throwing. And `install --force --dry-run` now previews the same resource set a real `--force` install would write (invalid resources were incorrectly dropped from the preview).
44 changes: 35 additions & 9 deletions src/cli/command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ function narrowFlags(flags: CliFlags): Record<string, string | boolean> {
return out;
}

/**
* True when an install/uninstall/refresh run may show an interactive prompt: a
* real TTY on both streams and neither --force nor --dry-run (both bypass every
* prompt). Such runs must NOT hold a spinner — its redraw timer overwrites the
* prompt line, hiding the question and hanging the command on stdin. `--json`
* maps to --force upstream for install/uninstall, and refresh reads json→force
* internally, so json runs never reach a prompt either way.
*/
function mayPrompt(nf: Record<string, string | boolean>): boolean {
return (
process.stdin.isTTY === true &&
process.stdout.isTTY === true &&
nf.force !== true &&
nf['dry-run'] !== true
);
}

export const cmdHandlers: Record<string, CommandHandler> = {
generate: async (flags, _args) => {
void _args;
Expand Down Expand Up @@ -161,31 +178,40 @@ export const cmdHandlers: Record<string, CommandHandler> = {
const nf = narrowFlags(flags);
if (nf.json === true) nf.force = true;
ui.intro('agentsmesh install');
const sp = ui.spinner();
sp.start('Installing…');
// An animated spinner and an interactive readline prompt cannot share a TTY:
// the spinner's redraw timer overwrites the prompt line, so the confirmation
// question is invisible and the install hangs waiting for stdin. When the run
// may prompt (real TTY, no --force/--dry-run), skip the spinner and let the
// prompt flow own the terminal.
const sp = mayPrompt(nf) ? null : ui.spinner();
sp?.start('Installing…');
const result = await runInstall(nf, args, process.cwd());
sp.stop('Install complete');
sp?.stop('Install complete');
handleResult('install', result, nf, () => renderInstall(result));
ui.outro('Done');
},
uninstall: async (flags, args) => {
const nf = narrowFlags(flags);
if (nf.json === true) nf.force = true;
ui.intro('agentsmesh uninstall');
const sp = ui.spinner();
sp.start('Removing…');
// See install: the spinner must yield the TTY to the interactive prompt flow.
const sp = mayPrompt(nf) ? null : ui.spinner();
sp?.start('Removing…');
const result = await runUninstall(nf, args, process.cwd());
sp.stop('Uninstall complete');
sp?.stop('Uninstall complete');
handleResult('uninstall', result, nf, () => renderUninstall(result));
ui.outro('Done');
},
refresh: async (flags, args) => {
const nf = narrowFlags(flags);
ui.intro('agentsmesh refresh');
const sp = ui.spinner();
sp.start('Refreshing…');
// See install: refresh reaches an interactive consent prompt (packs with
// local edits, no --force/--dry-run) via runConsentPrompt → readLine. The
// spinner must yield the TTY to that prompt or it hangs/times-out unseen.
const sp = mayPrompt(nf) ? null : ui.spinner();
sp?.start('Refreshing…');
const result = await runRefresh(nf, args, process.cwd());
sp.stop('Refresh complete');
sp?.stop('Refresh complete');
handleResult('refresh', result, nf, () => renderRefresh(result));
ui.outro('Done');
},
Expand Down
43 changes: 15 additions & 28 deletions src/install/core/pool-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,19 @@ export async function resolveSkillPool(
tty: boolean,
): Promise<CanonicalSkill[]> {
const skillCandidates = narrowed.skills.map((s) => validateSkill(s));
let pool = skillCandidates.filter((c) => c.ok).map((c) => c.skill);
const pool = skillCandidates.filter((c) => c.ok).map((c) => c.skill);
const invalid = skillCandidates.filter((c) => !c.ok);
if (!force && !dryRun && tty) {
// `force` includes invalid resources; a dry-run must preview the SAME set a
// real run would install, so force wins over dryRun here. dryRun only gates
// writes downstream (run-install-execute early-returns), never selection.
if (force) return skillCandidates.map((c) => c.skill);
if (!dryRun && tty) {
for (const inv of invalid) {
const ok = await confirm(
`Include invalid skill "${inv.skill.name}" anyway? (${inv.reason}). You can fix it later.`,
);
if (ok) pool.push(inv.skill);
}
} else if (force) {
pool = skillCandidates.map((c) => c.skill);
}
if (dryRun) {
pool = skillCandidates.filter((c) => c.ok).map((c) => c.skill);
}
return pool;
}
Expand All @@ -64,20 +63,16 @@ export async function resolveRulePool(
tty: boolean,
): Promise<CanonicalRule[]> {
const candidates = narrowed.rules.map((r) => validateRule(r));
let pool = candidates.filter((c) => c.ok).map((c) => c.rule);
const pool = candidates.filter((c) => c.ok).map((c) => c.rule);
const invalid = candidates.filter((c) => !c.ok);
if (!force && !dryRun && tty) {
if (force) return candidates.map((c) => c.rule);
if (!dryRun && tty) {
for (const inv of invalid) {
const ok = await confirm(
`Include invalid rule "${ruleSlug(inv.rule)}" anyway? (${inv.reason}). You can fix it later.`,
);
if (ok) pool.push(inv.rule);
}
} else if (force) {
pool = candidates.map((c) => c.rule);
}
if (dryRun) {
pool = candidates.filter((c) => c.ok).map((c) => c.rule);
}
return pool;
}
Expand All @@ -89,20 +84,16 @@ export async function resolveCommandPool(
tty: boolean,
): Promise<CanonicalCommand[]> {
const candidates = narrowed.commands.map((c) => validateCommand(c));
let pool = candidates.filter((c) => c.ok).map((c) => c.command);
const pool = candidates.filter((c) => c.ok).map((c) => c.command);
const invalid = candidates.filter((c) => !c.ok);
if (!force && !dryRun && tty) {
if (force) return candidates.map((c) => c.command);
if (!dryRun && tty) {
for (const inv of invalid) {
const ok = await confirm(
`Include invalid command "${inv.command.name}" anyway? (${inv.reason}). You can fix it later.`,
);
if (ok) pool.push(inv.command);
}
} else if (force) {
pool = candidates.map((c) => c.command);
}
if (dryRun) {
pool = candidates.filter((c) => c.ok).map((c) => c.command);
}
return pool;
}
Expand All @@ -114,20 +105,16 @@ export async function resolveAgentPool(
tty: boolean,
): Promise<CanonicalAgent[]> {
const candidates = narrowed.agents.map((a) => validateAgent(a));
let pool = candidates.filter((c) => c.ok).map((c) => c.agent);
const pool = candidates.filter((c) => c.ok).map((c) => c.agent);
const invalid = candidates.filter((c) => !c.ok);
if (!force && !dryRun && tty) {
if (force) return candidates.map((c) => c.agent);
if (!dryRun && tty) {
for (const inv of invalid) {
const ok = await confirm(
`Include invalid agent "${inv.agent.name}" anyway? (${inv.reason}). You can fix it later.`,
);
if (ok) pool.push(inv.agent);
}
} else if (force) {
pool = candidates.map((c) => c.agent);
}
if (dryRun) {
pool = candidates.filter((c) => c.ok).map((c) => c.agent);
}
return pool;
}
17 changes: 8 additions & 9 deletions src/install/core/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
/**
* Interactive install prompts (TTY only).
*
* Delegates to the shared `readLine` primitive so it inherits its EOF/error
* safety: a closed or erroring stdin resolves to '' → decline, instead of
* hanging on an unresolved promise (the old inline readline had no 'close'/
* 'error' handler and hung on Ctrl-D / crashed on stream errors).
*/

import * as readline from 'node:readline';
import { readLine } from '../prompts/prompt-io.js';

export async function confirm(message: string): Promise<boolean> {
if (!process.stdin.isTTY) return false;
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(`${message} (y/n) `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
resolve(a === 'y' || a === 'yes');
});
});
const answer = (await readLine(`${message} (y/n) `)).trim().toLowerCase();
return answer === 'y' || answer === 'yes';
}
10 changes: 10 additions & 0 deletions src/install/prompts/prompt-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export function readLine(prompt: string, options?: PromptIOOptions): Promise<str
rl.on('close', () => {
if (!answered) resolve('');
});
// A stream 'error' (broken pipe, terminal disconnect, SSH drop) would
// otherwise throw an unhandled event and crash the process. Collapse it to
// the same EOF contract: resolve '' so callers uniformly treat it as "no
// input" (decline) instead of hanging or crashing.
rl.on('error', () => {
if (!answered) {
answered = true;
resolve('');
}
});
rl.question(prompt, (answer) => {
answered = true;
rl.close();
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/cli/command-handlers-refresh-spinner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Refresh handler must skip the spinner when the run may prompt, so the
* refresh drift consent prompt (run-refresh.ts → runConsentPrompt → readLine)
* is not clobbered by the 'Refreshing…' spinner's redraw timer. Mirrors the
* install/uninstall spinner-gating tests.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { cmdHandlers } from '../../../src/cli/command-handlers.js';
import { runRefresh } from '../../../src/cli/commands/refresh.js';
import { handleResult } from '../../../src/cli/json-handler.js';
import { ui } from '../../../src/cli/ui/ui.js';

vi.mock('../../../src/cli/commands/refresh.js', () => ({ runRefresh: vi.fn() }));
vi.mock('../../../src/cli/renderers/refresh.js', () => ({ renderRefresh: vi.fn() }));
vi.mock('../../../src/cli/json-handler.js', () => ({ handleResult: vi.fn() }));

describe('cmdHandlers.refresh — spinner gating', () => {
const refreshResult = {
exitCode: 0,
data: { scope: 'project' as const, refreshed: [], unchanged: [], skipped: [], failed: [] },
};

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(runRefresh).mockResolvedValue(refreshResult);
vi.mocked(handleResult).mockImplementation((_command, _result, flags, render) => {
if (flags.json !== true) render();
});
});

function withTty<T>(fn: () => Promise<T>): Promise<T> {
const inDesc = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
const outDesc = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
return fn().finally(() => {
if (inDesc) Object.defineProperty(process.stdin, 'isTTY', inDesc);
if (outDesc) Object.defineProperty(process.stdout, 'isTTY', outDesc);
});
}

it('skips the spinner on an interactive TTY so the consent prompt is not clobbered', async () => {
const start = vi.fn();
const spinnerSpy = vi
.spyOn(ui, 'spinner')
.mockReturnValue({ start, stop: vi.fn(), message: vi.fn() });
try {
await withTty(() => cmdHandlers.refresh({}, []));
expect(ui.spinner).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
expect(runRefresh).toHaveBeenCalledWith({}, [], process.cwd());
} finally {
spinnerSpy.mockRestore();
}
});

it('runs the spinner when non-interactive (--force bypasses the consent prompt)', async () => {
const start = vi.fn();
const spinnerSpy = vi
.spyOn(ui, 'spinner')
.mockReturnValue({ start, stop: vi.fn(), message: vi.fn() });
try {
await withTty(() => cmdHandlers.refresh({ force: true }, []));
expect(start).toHaveBeenCalledWith('Refreshing…');
} finally {
spinnerSpy.mockRestore();
}
});
});
Loading
Loading