From d511bc5b18a8a6e03f30964eb30f7bf7ee4fc30b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 10 Jul 2026 15:21:12 -0400 Subject: [PATCH] feat(cli): improve diagnostic output --- docs/cli.md | 5 + docs/integrations.md | 5 + src/commands/doctor.ts | 188 +++++++++++++++++++++++------- src/commands/setup.ts | 216 +++++++++++++++++++---------------- src/ui/display.ts | 182 ++++++++++++++++++++++++++++- tests/display.test.ts | 41 +++++++ tests/doctor-command.test.ts | 43 +++++++ tests/setup.test.ts | 87 ++++++++++++++ 8 files changed, 623 insertions(+), 144 deletions(-) create mode 100644 tests/display.test.ts diff --git a/docs/cli.md b/docs/cli.md index a78bee83..aa7a3322 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,6 +26,11 @@ terminal). - `hack setup` — install/refresh agent integrations (Cursor rules, Claude hooks, Codex skill, MCP) - `hack tickets` — deprecated compatibility surface for existing Tickets data +Interactive diagnostics use compact status rows: healthy groups stay on one line, while warnings +and errors expand with wrapped detail and recovery guidance. `hack doctor --json` remains the stable, +fully detailed automation surface. Generic macOS resolver setup is shown only when those resolver +checks need attention. + Run `hack help` for the full command list, or `hack help --all` to include hidden unsupported experimental commands. Every command and flag on this page is also in the generated [CLI reference](reference/cli.md). diff --git a/docs/integrations.md b/docs/integrations.md index f354f086..b27a0dc1 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -9,6 +9,11 @@ What remains: - optional coding-agent setup helpers: `hack init --with claude|codex|both`, `hack agent onboard` / `hack agent init` / `hack agent prime`, and `hack setup sync --all-scopes` +`hack setup sync` keeps interactive output compact: it summarizes each scope and only expands the +path and reason for stale, missing, or failed artifacts. Exit status remains the automation contract, +and the individual `hack setup cursor|claude|codex|agents|mcp --check` commands remain available when +you need per-artifact detail. + What was removed: - Hack Tickets agent integration; legacy commands remain compatibility-only and are deprecated diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index fd4dfead..de039817 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -112,7 +112,7 @@ import { renderGlobalCaddyCompose, renderGlobalCoreDnsConfig, } from "../templates.ts"; -import { display } from "../ui/display.ts"; +import { type DisplayStatusItem, display } from "../ui/display.ts"; import { getFzfPath } from "../ui/fzf.ts"; import { getGumPath } from "../ui/gum.ts"; import { @@ -609,7 +609,7 @@ const handleDoctor: CommandHandlerFor = async ({ await renderDoctorSummary(results); emitSlowChecksNote(results); - renderMacNote(results); + await renderMacNote(results); if (!args.options.fix) { await renderRecoveryGuidance(results); } @@ -3104,11 +3104,18 @@ async function ensureIngressNetwork(): Promise<{ } if (ingress.exists) { - await run(["docker", "network", "rm", DEFAULT_INGRESS_NETWORK], { - stdin: "inherit", - }); + const removed = await exec( + ["docker", "network", "rm", DEFAULT_INGRESS_NETWORK], + { + stdin: "ignore", + } + ); + if (removed.exitCode !== 0) { + note(commandFailureDetail({ result: removed }), "runtime repair"); + return ingress; + } } - await run( + const created = await exec( [ "docker", "network", @@ -3119,8 +3126,11 @@ async function ensureIngressNetwork(): Promise<{ "--gateway", DEFAULT_INGRESS_GATEWAY, ], - { stdin: "inherit" } + { stdin: "ignore" } ); + if (created.exitCode !== 0) { + note(commandFailureDetail({ result: created }), "runtime repair"); + } return await inspectDockerNetwork(DEFAULT_INGRESS_NETWORK); } @@ -3131,9 +3141,15 @@ async function ensureLoggingNetwork(): Promise { return; } - await run(["docker", "network", "create", DEFAULT_LOGGING_NETWORK], { - stdin: "inherit", - }); + const created = await exec( + ["docker", "network", "create", DEFAULT_LOGGING_NETWORK], + { + stdin: "ignore", + } + ); + if (created.exitCode !== 0) { + note(commandFailureDetail({ result: created }), "runtime repair"); + } } async function maybeStartGlobalCaddyCompose(opts: { @@ -3143,7 +3159,7 @@ async function maybeStartGlobalCaddyCompose(opts: { return; } - await run( + const started = await exec( [ "docker", "compose", @@ -3155,9 +3171,27 @@ async function maybeStartGlobalCaddyCompose(opts: { ], { cwd: dirname(opts.paths.caddyCompose), - stdin: "inherit", + stdin: "ignore", } ); + if (started.exitCode !== 0) { + note(commandFailureDetail({ result: started }), "runtime repair"); + return; + } + note("CoreDNS and Caddy are ready.", "runtime repair"); +} + +function commandFailureDetail(input: { + readonly result: { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + }; +}): string { + const detail = input.result.stderr.trim() || input.result.stdout.trim(); + return detail.length > 0 + ? `Repair command failed: ${detail}` + : `Repair command failed (exit ${input.result.exitCode}).`; } async function maybeExportCaddyCaCert(opts: { @@ -3562,26 +3596,85 @@ function isHiddenDoctorCheck(result: RecoveryCheckResult): boolean { return result.name === "tickets git"; } +export function buildDoctorSummaryStatusItems(input: { + readonly results: readonly RecoveryCheckResult[]; +}): readonly DisplayStatusItem[] { + const items: DisplayStatusItem[] = []; + + for (const group of DOCTOR_SUMMARY_GROUPS) { + const members = input.results.filter((result) => + group.checks.has(result.name) + ); + if (members.length === 0) { + continue; + } + items.push(buildDoctorSummaryStatusItem({ title: group.title, members })); + } + + const ungrouped = input.results.filter( + (result) => + !( + isHiddenDoctorCheck(result) || + DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) + ) + ); + if (ungrouped.length > 0) { + items.push( + buildDoctorSummaryStatusItem({ + title: "Other checks", + members: ungrouped, + }) + ); + } + + return items; +} + +function buildDoctorSummaryStatusItem(input: { + readonly title: string; + readonly members: readonly RecoveryCheckResult[]; +}): DisplayStatusItem { + const issues = input.members.filter( + (result) => result.status !== "ok" && !isIgnorableDoctorSummaryIssue(result) + ); + const errorCount = issues.filter( + (result) => result.status === "error" + ).length; + const warningCount = issues.length - errorCount; + let status: DisplayStatusItem["status"] = "ok"; + if (errorCount > 0) { + status = "error"; + } else if (warningCount > 0) { + status = "warn"; + } + + const issueCounts = [ + errorCount > 0 ? `${errorCount} error${errorCount === 1 ? "" : "s"}` : null, + warningCount > 0 + ? `${warningCount} warning${warningCount === 1 ? "" : "s"}` + : null, + ].filter((part): part is string => part !== null); + + return { + label: input.title, + status, + meta: + issueCounts.length > 0 + ? issueCounts.join(", ") + : `${input.members.length} checks`, + detail: + issues.length > 0 + ? summarizeDoctorGroupIssues({ title: input.title, issues }) + : undefined, + }; +} + async function renderDoctorSummary( results: readonly TimedCheckResult[] ): Promise { - const summaryLines = buildDoctorSummaryLines({ results }); - let tone: "error" | "warn" | "info" = "info"; - if (results.some((result) => result.status === "error")) { - tone = "error"; - } else if ( - results.some( - (result) => - result.status === "warn" && !isIgnorableDoctorSummaryIssue(result) - ) - ) { - tone = "warn"; - } - - await display.panel({ + await display.statusList({ title: "Doctor summary", - tone, - lines: summaryLines, + items: buildDoctorSummaryStatusItems({ results }), }); } @@ -3649,23 +3742,34 @@ function summarizeDoctorIssue(issue: RecoveryCheckResult): string { return `${issue.name}: ${issue.message}`; } -function renderMacNote(results: readonly RecoveryCheckResult[]): void { - const dnsNeedsSetup = results.some( +async function renderMacNote( + results: readonly TimedCheckResult[] +): Promise { + const hasResolverIssue = results.some( (result) => - DOCTOR_SUMMARY_GROUPS.find( - (group) => group.title === "Resolver & DNS" - )?.checks.has(result.name) && result.status !== "ok" + result.status !== "ok" && + (result.name.startsWith("resolver:") || + result.name.startsWith("dnsmasq.conf:")) ); - if (isMac() && dnsNeedsSetup) { - note( - [ - "macOS tip:", - `- wildcard DNS: /etc/resolver/${DEFAULT_PROJECT_TLD} + dnsmasq address=/.${DEFAULT_PROJECT_TLD}/`, - `- OAuth alias DNS: /etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT} + dnsmasq address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/`, - ].join("\n"), - "doctor" - ); + if (!(isMac() && hasResolverIssue)) { + return; } + + await display.statusList({ + title: "macOS resolver setup", + items: [ + { + label: `*.${DEFAULT_PROJECT_TLD}`, + status: "info", + detail: `/etc/resolver/${DEFAULT_PROJECT_TLD} with dnsmasq address=/.${DEFAULT_PROJECT_TLD}/`, + }, + { + label: `*.${DEFAULT_OAUTH_ALIAS_ROOT}`, + status: "info", + detail: `/etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT} with dnsmasq address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/`, + }, + ], + }); } async function resolvePreferredMacHostDnsTarget(): Promise { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 24a2dfe1..594cb3c5 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -61,6 +61,7 @@ import { installMcpConfig, removeMcpConfig, } from "../mcp/install.ts"; +import { type DisplayStatusItem, display } from "../ui/display.ts"; import { logger } from "../ui/logger.ts"; const optCheck = defineOption({ @@ -708,6 +709,80 @@ async function handleSetupAgents({ type SetupSyncAction = "install" | "check" | "remove"; +type SetupSyncGroup = { + readonly label: string; + readonly results: readonly SetupMultiLogResult[]; +}; + +type SetupSyncScopeResult = { + readonly exitCode: number; + readonly item: DisplayStatusItem; +}; + +export function buildSetupSyncScopeResult(input: { + readonly action: SetupSyncAction; + readonly scope: "Project" | "Global"; + readonly groups: readonly SetupSyncGroup[]; +}): SetupSyncScopeResult { + const entries = input.groups.flatMap((group) => + group.results.map((result) => ({ ...result, label: group.label })) + ); + const failures = entries.filter((entry) => { + if (entry.status === "error") { + return true; + } + return ( + input.action === "check" && + ["missing", "stale", "deprecated"].includes(entry.status) + ); + }); + const errorCount = failures.filter( + (entry) => entry.status === "error" + ).length; + let status: DisplayStatusItem["status"] = "ok"; + if (errorCount > 0) { + status = "error"; + } else if (failures.length > 0) { + status = "warn"; + } + const meta = (() => { + if (input.action === "check") { + return failures.length === 0 + ? `${entries.length} current` + : `${entries.length - failures.length}/${entries.length} current`; + } + if (input.action === "remove") { + const removed = entries.filter( + (entry) => entry.status === "removed" + ).length; + return `${removed} removed`; + } + const changed = entries.filter((entry) => + ["created", "updated", "removed"].includes(entry.status) + ).length; + return changed === 0 ? "already current" : `${changed} updated`; + })(); + const detail = failures + .map((entry) => { + if (entry.message) { + return `${entry.label}: ${entry.message}`; + } + const location = entry.path ? ` at ${entry.path}` : ""; + return `${entry.label}: ${entry.status}${location}`; + }) + .join("\n"); + + return { + exitCode: failures.length > 0 ? 1 : 0, + item: { + label: input.scope, + status, + meta, + detail: detail.length > 0 ? detail : undefined, + }, + }; +} + async function handleSetupSync({ ctx, args, @@ -725,11 +800,10 @@ async function handleSetupSync({ const projectRoot = includesProject ? await resolveSetupRoot({ ctx, pathOpt: args.options.path }) : undefined; - let exitCode = 0; + const scopeResults: SetupSyncScopeResult[] = []; if (includesProject && projectRoot) { - exitCode = Math.max( - exitCode, + scopeResults.push( await runProjectScopeSync({ action, projectRoot, @@ -738,10 +812,19 @@ async function handleSetupSync({ } if (includesUser) { - exitCode = Math.max(exitCode, await runUserScopeSync({ action })); + scopeResults.push(await runUserScopeSync({ action })); } - return exitCode; + const titles: Readonly> = { + check: "Agent integrations", + install: "Agent integrations updated", + remove: "Agent integrations removed", + }; + await display.statusList({ + title: titles[action], + items: scopeResults.map((result) => result.item), + }); + return Math.max(0, ...scopeResults.map((result) => result.exitCode)); } /** @@ -751,7 +834,7 @@ async function handleSetupSync({ async function runProjectScopeSync(opts: { readonly action: SetupSyncAction; readonly projectRoot: string; -}): Promise { +}): Promise { const { action, projectRoot } = opts; let cursorResult: Awaited>; let claudeResult: Awaited>; @@ -820,56 +903,19 @@ async function runProjectScopeSync(opts: { }); } - const singleResults: readonly (readonly [ - SetupMultiLogResult & { readonly path: string }, - string, - ])[] = [ - [cursorResult, "Cursor integration (project)"], - [claudeResult, "Claude integration (project)"], - [codexResult, "Codex integration (project)"], - ]; - - let exitCode = 0; - for (const [result, okMessage] of singleResults) { - exitCode = Math.max( - exitCode, - logSingleResult({ action, okMessage, result }) - ); - } - const cleanupAction = action === "check" ? "check" : "remove"; - exitCode = Math.max( - exitCode, - logSingleResult({ - action: cleanupAction, - okMessage: "Deprecated Tickets skill (project)", - result: ticketsResult, - }) - ); - exitCode = Math.max( - exitCode, - logMultiResults({ - action: cleanupAction, - okMessage: "Deprecated Tickets instructions", - results: ticketsDocsResults, - }) - ); - exitCode = Math.max( - exitCode, - logMultiResults({ - action, - okMessage: "MCP config (project)", - results: mcpResults, - }) - ); - exitCode = Math.max( - exitCode, - logMultiResults({ - action, - okMessage: "Agent docs", - results: docsResults, - }) - ); - return exitCode; + return buildSetupSyncScopeResult({ + action, + scope: "Project", + groups: [ + { label: "Cursor", results: [cursorResult] }, + { label: "Claude", results: [claudeResult] }, + { label: "Codex", results: [codexResult] }, + { label: "Deprecated Tickets skill", results: [ticketsResult] }, + { label: "Deprecated Tickets instructions", results: ticketsDocsResults }, + { label: "MCP config", results: mcpResults }, + { label: "Agent docs", results: docsResults }, + ], + }); } /** @@ -879,7 +925,7 @@ async function runProjectScopeSync(opts: { */ async function runUserScopeSync(opts: { readonly action: SetupSyncAction; -}): Promise { +}): Promise { const { action } = opts; let cursorResult: Awaited>; let claudeResult: Awaited>; @@ -924,49 +970,19 @@ async function runUserScopeSync(opts: { }); } - const singleResults: readonly (readonly [ - SetupMultiLogResult & { readonly path: string }, - string, - ])[] = [ - [cursorResult, "Cursor integration (global)"], - [claudeResult, "Claude integration (global)"], - [codexResult, "Codex integration (global)"], - [sharedSkillResult, "Shared Hack skill (global)"], - ]; - - let exitCode = 0; - for (const [result, okMessage] of singleResults) { - exitCode = Math.max( - exitCode, - logSingleResult({ action, okMessage, result }) - ); - } - const cleanupAction = action === "check" ? "check" : "remove"; - exitCode = Math.max( - exitCode, - logSingleResult({ - action: cleanupAction, - okMessage: "Deprecated Tickets skill (global)", - result: ticketsResult, - }) - ); - exitCode = Math.max( - exitCode, - logMultiResults({ - action: cleanupAction, - okMessage: "Deprecated shared Hack skills", - results: legacySharedResults, - }) - ); - exitCode = Math.max( - exitCode, - logMultiResults({ - action, - okMessage: "MCP config (global)", - results: mcpResults, - }) - ); - return exitCode; + return buildSetupSyncScopeResult({ + action, + scope: "Global", + groups: [ + { label: "Cursor", results: [cursorResult] }, + { label: "Claude", results: [claudeResult] }, + { label: "Codex", results: [codexResult] }, + { label: "Shared Hack skill", results: [sharedSkillResult] }, + { label: "Deprecated Tickets skill", results: [ticketsResult] }, + { label: "Deprecated shared Hack skills", results: legacySharedResults }, + { label: "MCP config", results: mcpResults }, + ], + }); } async function handleSetupMcp({ diff --git a/src/ui/display.ts b/src/ui/display.ts index f5ad2670..6cf4db49 100644 --- a/src/ui/display.ts +++ b/src/ui/display.ts @@ -3,6 +3,15 @@ import { isColorEnabled, isTty } from "./terminal.ts"; export type DisplayCell = string | number | boolean | null | undefined; +export type DisplayStatus = "ok" | "warn" | "error" | "info"; + +export type DisplayStatusItem = { + readonly label: string; + readonly status: DisplayStatus; + readonly detail?: string; + readonly meta?: string; +}; + export interface Display { /** * Render a section heading. This is for UI output, not structured logs. @@ -34,6 +43,15 @@ export interface Display { readonly tone?: "info" | "success" | "warn" | "error"; }): Promise; + /** + * Render compact diagnostic rows with optional indented detail. + * Healthy rows stay terse while warnings and errors can explain themselves. + */ + statusList(input: { + readonly title?: string; + readonly items: readonly DisplayStatusItem[]; + }): Promise; + /** * Render blocks side-by-side when possible. */ @@ -44,6 +62,149 @@ function writeLine(text: string): void { process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); } +const DEFAULT_TERMINAL_WIDTH = 80; +const MAX_READING_WIDTH = 100; +const LEADING_WHITESPACE_PATTERN = /^\s*/; +const LIST_PREFIX_PATTERN = /^(?:[-*]|\d+\.)\s+/; +const WHITESPACE_PATTERN = /\s+/; + +function resolveReadingWidth(): number { + const terminalWidth = + typeof process.stdout.columns === "number" && process.stdout.columns > 0 + ? process.stdout.columns + : DEFAULT_TERMINAL_WIDTH; + return Math.max(40, Math.min(MAX_READING_WIDTH, terminalWidth - 2)); +} + +function wrapLine(input: { + readonly text: string; + readonly width: number; +}): string[] { + if (input.text.length <= input.width) { + return [input.text]; + } + + const words = input.text.trim().split(WHITESPACE_PATTERN); + const lines: string[] = []; + let current = ""; + for (const word of words) { + if (current.length === 0) { + current = word; + continue; + } + if (current.length + word.length + 1 <= input.width) { + current = `${current} ${word}`; + continue; + } + lines.push(current); + current = word; + } + if (current.length > 0) { + lines.push(current); + } + return lines; +} + +export function buildStatusListLines(input: { + readonly items: readonly DisplayStatusItem[]; + readonly width: number; +}): readonly string[] { + const symbols: Readonly> = { + ok: "✓", + warn: "!", + error: "×", + info: "•", + }; + const labelWidth = Math.max( + 0, + ...input.items.map((item) => item.label.length) + ); + const lines: string[] = []; + + for (const item of input.items) { + const paddedLabel = item.label.padEnd(labelWidth); + const meta = item.meta ? ` ${item.meta}` : ""; + lines.push(`${symbols[item.status]} ${paddedLabel}${meta}`.trimEnd()); + if (!item.detail) { + continue; + } + const detailIndent = " "; + const detailWidth = Math.max(20, input.width - detailIndent.length); + for (const paragraph of item.detail.split("\n")) { + for (const detailLine of wrapLine({ + text: paragraph, + width: detailWidth, + })) { + lines.push(`${detailIndent}${detailLine}`); + } + } + } + + return lines; +} + +export function buildPanelLines(input: { + readonly lines: readonly string[]; + readonly width: number; +}): readonly string[] { + return input.lines.flatMap((line) => { + if (line.length <= input.width) { + return [line]; + } + const leadingWhitespace = line.match(LEADING_WHITESPACE_PATTERN)?.[0] ?? ""; + const body = line.slice(leadingWhitespace.length); + const bullet = body.match(LIST_PREFIX_PATTERN)?.[0] ?? ""; + const firstPrefix = `${leadingWhitespace}${bullet}`; + const continuationPrefix = " ".repeat(firstPrefix.length); + const content = body.slice(bullet.length); + const contentWidth = Math.max(20, input.width - firstPrefix.length); + return wrapLine({ text: content, width: contentWidth }).map( + (part, index) => + `${index === 0 ? firstPrefix : continuationPrefix}${part}` + ); + }); +} + +function statusListWithAnsi(input: { + readonly title?: string; + readonly items: readonly DisplayStatusItem[]; +}): void { + const enableColor = isColorEnabled(); + const RESET = "\x1b[0m"; + const BOLD = "\x1b[1m"; + const FAINT = "\x1b[2m"; + const colors: Readonly> = { + ok: "\x1b[32m", + warn: "\x1b[33m", + error: "\x1b[31m", + info: "\x1b[36m", + }; + const lines = buildStatusListLines({ + items: input.items, + width: resolveReadingWidth(), + }); + + writeLine(""); + if (input.title) { + writeLine(enableColor ? `${BOLD}${input.title}${RESET}` : input.title); + } + let itemIndex = 0; + for (const line of lines) { + const isDetail = line.startsWith(" "); + if (isDetail) { + writeLine(enableColor ? `${FAINT}${line}${RESET}` : line); + continue; + } + const status = input.items[itemIndex]?.status ?? "info"; + const symbol = line.slice(0, 1); + const remainder = line.slice(1); + writeLine( + enableColor ? `${colors[status]}${symbol}${RESET}${remainder}` : line + ); + itemIndex += 1; + } +} + function sanitizeCell(value: string): string { return value.replaceAll("\t", " ").replaceAll("\n", " "); } @@ -175,14 +336,24 @@ async function panelWithGum(input: { error: "196", }[tone] ?? "212"; + const readingWidth = resolveReadingWidth(); + const contentWidth = readingWidth - 4; + const panelLines = buildPanelLines({ + lines: input.lines, + width: contentWidth, + }); const text = - titleRaw.length > 0 ? [titleRaw, ...input.lines] : [...input.lines]; + titleRaw.length > 0 ? [titleRaw, ...panelLines] : [...panelLines]; + const needsWidthConstraint = input.lines.some( + (line) => line.length > contentWidth + ); const res = await gumStyle({ text, border: "rounded", borderForeground, padding: "0 1", margin: "1 0 0 0", + width: needsWidthConstraint ? readingWidth : undefined, }); if (!res.ok) { return false; @@ -200,7 +371,10 @@ function panelWithAnsi(input: { if (titleRaw.length > 0) { writeLine(titleRaw); } - for (const line of input.lines) { + for (const line of buildPanelLines({ + lines: input.lines, + width: resolveReadingWidth() - 2, + })) { writeLine(` ${line}`); } } @@ -307,6 +481,10 @@ export const display: Display = { } panelWithAnsi(input); }, + statusList: (input) => { + statusListWithAnsi(input); + return Promise.resolve(); + }, columns: async (input) => { if (await columnsWithGum(input)) { return; diff --git a/tests/display.test.ts b/tests/display.test.ts new file mode 100644 index 00000000..1fbf9cb0 --- /dev/null +++ b/tests/display.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; + +import { buildPanelLines, buildStatusListLines } from "../src/ui/display.ts"; + +test("status list aligns rows and wraps diagnostic detail", () => { + const lines = buildStatusListLines({ + width: 44, + items: [ + { label: "Runtime", status: "ok", meta: "12 checks" }, + { + label: "Project & env", + status: "warn", + meta: "2 warnings", + detail: + "Three projects have services stuck in Created and need a targeted restart.", + }, + ], + }); + + expect(lines).toEqual([ + "✓ Runtime 12 checks", + "! Project & env 2 warnings", + " Three projects have services stuck in", + " Created and need a targeted restart.", + ]); +}); + +test("panel lines keep bullet indentation when wrapping", () => { + const lines = buildPanelLines({ + width: 36, + lines: [ + " - daemon: hackd is running but incompatible (run: hack daemon restart)", + ], + }); + + expect(lines).toEqual([ + " - daemon: hackd is running but", + " incompatible (run: hack daemon", + " restart)", + ]); +}); diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index d8b12dc5..dea88802 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -7,6 +7,7 @@ import { assertDoctorOptionCompatibility, buildDoctorRemediationPlanLines, buildDoctorSummaryLines, + buildDoctorSummaryStatusItems, inspectDoctorAgentIntegrations, } from "../src/commands/doctor.ts"; import { @@ -417,6 +418,48 @@ test("doctor summary groups detailed checks into concise sections", () => { ]); }); +test("doctor status summary separates health, counts, and wrapped detail", () => { + const items = buildDoctorSummaryStatusItems({ + results: [ + { name: "bun", status: "ok", message: "/usr/local/bin/bun" }, + { name: "docker daemon", status: "ok", message: "Docker is running" }, + { + name: "runtime hygiene", + status: "warn", + message: + "3 projects have services stuck in Created (run: hack doctor --fix)", + }, + { + name: "lifecycle hygiene", + status: "warn", + message: "1 stale state entry; 1 session collision", + }, + ], + }); + + expect(items).toEqual([ + { + label: "Dependencies", + status: "ok", + meta: "1 checks", + detail: undefined, + }, + { + label: "Global runtime & agents", + status: "ok", + meta: "1 checks", + detail: undefined, + }, + { + label: "Project & env", + status: "warn", + meta: "2 warnings", + detail: + "runtime hygiene: 3 projects have services stuck in Created (run: hack doctor --fix); lifecycle hygiene: 1 stale state entry; 1 session collision", + }, + ]); +}); + test("recovery next steps quote repo paths for copy-paste safety", () => { const nextSteps = buildRecoveryNextSteps({ guidance: { diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 90ef6afe..fd0c88b0 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -11,6 +11,7 @@ import { } from "../src/agents/init-assistant.ts"; import { renderAgentInitPatterns } from "../src/agents/init-patterns.ts"; import { renderAgentPrimer } from "../src/agents/primer.ts"; +import { buildSetupSyncScopeResult } from "../src/commands/setup.ts"; import { renderAgentDocsSnippet } from "../src/mcp/agent-docs.ts"; let tempDir: string | null = null; @@ -109,6 +110,92 @@ test("renderAgentInitPatterns includes dependency signals", () => { expect(patterns).toContain("docker-compose.yml"); }); +test("setup sync collapses healthy artifacts into one scope row", () => { + const result = buildSetupSyncScopeResult({ + action: "check", + scope: "Project", + groups: [ + { + label: "Cursor", + results: [{ status: "noop", path: "/repo/.cursor/rules/hack.mdc" }], + }, + { + label: "MCP config", + results: [ + { status: "present", path: "/repo/.cursor/mcp.json" }, + { status: "present", path: "/repo/.codex/config.toml" }, + ], + }, + ], + }); + + expect(result).toEqual({ + exitCode: 0, + item: { + label: "Project", + status: "ok", + meta: "3 current", + detail: undefined, + }, + }); +}); + +test("setup sync keeps failing artifact paths visible", () => { + const result = buildSetupSyncScopeResult({ + action: "check", + scope: "Global", + groups: [ + { + label: "Codex", + results: [{ status: "stale", path: "/.codex/skill.md" }], + }, + { + label: "Claude", + results: [{ status: "noop", path: "/.claude/settings.json" }], + }, + ], + }); + + expect(result.exitCode).toBe(1); + expect(result.item).toEqual({ + label: "Global", + status: "warn", + meta: "1/2 current", + detail: "Codex: stale at /.codex/skill.md", + }); +}); + +test("setup sync treats installed deprecated artifacts as actionable", () => { + const result = buildSetupSyncScopeResult({ + action: "check", + scope: "Global", + groups: [ + { + label: "Deprecated shared Hack skills", + results: [ + { + status: "deprecated", + path: "/.ai/skills/hack/SKILL.md", + message: "Deprecated Hack skill is still installed", + }, + { status: "absent", path: "/.ai/skills/hack-tickets/SKILL.md" }, + ], + }, + ], + }); + + expect(result).toEqual({ + exitCode: 1, + item: { + label: "Global", + status: "warn", + meta: "1/2 current", + detail: + "Deprecated shared Hack skills: Deprecated Hack skill is still installed", + }, + }); +}); + test("buildInitAssistantReport captures repo signals", async () => { const repoRoot = await setupTempRepo(); await Bun.write(