feat(cli): one interactive codegraph daemon command, replaces stop/list (#863)

Collapses the unreleased daemon controls into a single interactive command.
`codegraph daemon` (alias `daemons`) opens an arrow-key picker (current project's
daemon first, pre-selected), enter stops it, or pick "Stop all"; non-TTY prints a
plain list. Removes stop/list/ps; reuses the unchanged daemon-registry machinery;
the pick->stop loop is in daemon-manager.ts behind an injectable select (unit
tested). Validated live on macOS/Linux (real clack picker driven via pty) and
Windows (real runDaemonPicker + stopDaemonAt against a real daemon). Closes #845
follow-up.
This commit is contained in:
Colby Mchenry
2026-06-13 13:53:38 -05:00
committed by GitHub
parent 0f825649a1
commit ff288ac711
4 changed files with 267 additions and 58 deletions
+36 -57
View File
@@ -1269,72 +1269,51 @@ function printFileTree(
}
/**
* codegraph stop — stop the background daemon for a project (or --all).
* codegraph daemon — interactive manager for the background daemons. Arrow keys
* to pick one (the current project's daemon floats to the top, auto-selected),
* enter to stop it. Falls back to a plain list when output isn't a TTY.
*/
program
.command('stop [path]')
.description('Stop the background CodeGraph daemon for a project (defaults to the current one)')
.option('-a, --all', 'Stop every running CodeGraph daemon on this machine')
.action(async (pathArg: string | undefined, options: { all?: boolean }) => {
const { stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry');
try {
if (options.all) {
const results = await stopAllDaemons();
const stopped = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill');
if (stopped.length === 0) {
info('No running CodeGraph daemons.');
return;
}
for (const r of stopped) {
success(`Stopped daemon (pid ${r.pid}${r.outcome === 'kill' ? ', forced' : ''}) — ${r.root}`);
}
return;
}
.command('daemon')
.aliases(['daemons'])
.description('Manage running CodeGraph background daemons — pick one and press enter to stop it')
.action(async () => {
const { listDaemons, stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry');
const { runDaemonPicker } = await import('../mcp/daemon-manager');
const found = findNearestCodeGraphRoot(path.resolve(pathArg || process.cwd()));
if (!found) {
error('No CodeGraph project found here. Run inside a project, pass a path, or use --all.');
process.exit(1);
}
let root = found;
try { root = fs.realpathSync(found); } catch { /* fall back to the un-realpath'd root */ }
const result = await stopDaemonAt(root);
if (result.outcome === 'no-daemon' || result.outcome === 'not-running') {
info(`No daemon running for ${root}.`);
} else {
success(`Stopped daemon (pid ${result.pid}${result.outcome === 'kill' ? ', forced' : ''}) for ${root}.`);
}
} catch (err) {
error(`Failed to stop daemon: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
/**
* codegraph list — show running background daemons.
*/
program
.command('list')
.alias('ps')
.description('List running CodeGraph background daemons')
.option('--json', 'Output as JSON')
.action(async (options: { json?: boolean }) => {
const { listDaemons } = await import('../mcp/daemon-registry');
const daemons = listDaemons();
if (options.json) {
process.stdout.write(JSON.stringify(daemons, null, 2) + '\n');
return;
}
if (daemons.length === 0) {
info('No CodeGraph daemons running.');
return;
}
for (const d of daemons) {
console.log(`pid ${d.pid} v${d.version} up ${formatDuration(Date.now() - d.startedAt)} ${d.root}`);
// No TTY (piped / CI / non-interactive) — can't do arrow-key selection, so
// just print what's running instead of crashing on a prompt with no input.
if (!process.stdout.isTTY || !process.stdin.isTTY) {
for (const d of daemons) {
console.log(`pid ${d.pid} v${d.version} up ${formatDuration(Date.now() - d.startedAt)} ${d.root}`);
}
return;
}
info('Stop one with "codegraph stop <path>", or all with "codegraph stop --all".');
// The current project's daemon floats to the top and is pre-selected.
let cwdRoot: string | null = null;
const found = findNearestCodeGraphRoot(process.cwd());
if (found) { try { cwdRoot = fs.realpathSync(found); } catch { cwdRoot = found; } }
const clack = await importESM('@clack/prompts');
clack.intro('CodeGraph daemons');
await runDaemonPicker({
list: listDaemons,
stop: stopDaemonAt,
stopAll: stopAllDaemons,
cwdRoot,
now: () => Date.now(),
select: (opts) => clack.select(opts),
isCancel: (v) => clack.isCancel(v),
note: (m) => clack.log.success(m),
done: (m) => clack.outro(m),
});
});
/**
+117
View File
@@ -0,0 +1,117 @@
/**
* Interactive daemon manager — the logic behind `codegraph daemon` / `daemons`.
*
* Kept separate from the CLI (which owns the @clack/prompts wiring) so the
* selection/stop loop is unit-testable with a fake `select`: no TTY, no clack,
* no real daemons. The CLI passes the real clack `select`/`isCancel` plus the
* registry's list/stop functions.
*/
import * as path from 'path';
import type { DaemonRecord, StopResult } from './daemon-registry';
/** Sentinel option values (not real roots, so they can't collide with a project path). */
export const STOP_ALL = '__stop_all__';
export const CANCEL = '__cancel__';
export interface PickItem {
value: string;
label: string;
hint?: string;
}
/** Compact uptime: `45s`, `12m`, `3h 5m`. */
export function formatUptime(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
/**
* Build the ordered, UI-ready option list: the current project's daemon first
* (so it's the auto-selected default), the rest newest-first, then "Stop all"
* (only when there's more than one) and "Cancel".
*/
export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null, now: number): PickItem[] {
const cwd = cwdRoot != null ? path.resolve(cwdRoot) : null;
const ordered = [...daemons].sort((a, b) => {
if (cwd) {
const aCur = path.resolve(a.root) === cwd;
const bCur = path.resolve(b.root) === cwd;
if (aCur && !bCur) return -1;
if (bCur && !aCur) return 1;
}
return b.startedAt - a.startedAt; // newest first
});
const items: PickItem[] = ordered.map((d) => {
const current = cwd != null && path.resolve(d.root) === cwd;
return {
value: d.root,
label: current ? `${d.root} (current project)` : d.root,
hint: `pid ${d.pid} · up ${formatUptime(now - d.startedAt)} · Running`,
};
});
if (items.length > 1) items.push({ value: STOP_ALL, label: 'Stop all', hint: '' });
items.push({ value: CANCEL, label: 'Cancel', hint: '' });
return items;
}
export interface PickerDeps {
list: () => DaemonRecord[];
stop: (root: string) => Promise<StopResult>;
stopAll: () => Promise<StopResult[]>;
/** Realpath'd root of the current project's daemon, or null. */
cwdRoot: string | null;
now: () => number;
/** Render the picker; returns the chosen value or a cancel sentinel. */
select: (opts: { message: string; options: PickItem[]; initialValue: string }) => Promise<unknown>;
isCancel: (v: unknown) => boolean;
/** Per-action note (e.g. "Stopped daemon …"). */
note: (msg: string) => void;
/** Final line + teardown (clack outro). */
done: (msg: string) => void;
}
/**
* Pick a daemon → stop it → re-prompt with what's left, until the user cancels
* (Esc / Ctrl-C / "Cancel"), picks "Stop all", or nothing remains.
*/
export async function runDaemonPicker(deps: PickerDeps): Promise<void> {
for (;;) {
const daemons = deps.list();
if (daemons.length === 0) {
deps.done('All daemons stopped.');
return;
}
const items = buildPickItems(daemons, deps.cwdRoot, deps.now());
const choice = await deps.select({
message: 'Select a daemon to stop',
options: items,
initialValue: items[0]?.value ?? CANCEL, // daemons.length > 0 here, so items[0] is a daemon
});
if (deps.isCancel(choice) || choice === CANCEL) {
deps.done('Cancelled.');
return;
}
if (choice === STOP_ALL) {
const results = await deps.stopAll();
const n = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill').length;
deps.note(`Stopped ${n} daemon${n === 1 ? '' : 's'}.`);
deps.done('Done.');
return;
}
const result = await deps.stop(String(choice));
const forced = result.outcome === 'kill' ? ', forced' : '';
deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
// Loop: the next iteration re-lists; if more remain it re-prompts, otherwise
// the top-of-loop empty check prints "All daemons stopped."
}
}