Skip to content

Commit db47c68

Browse files
authored
feat(cli): improve diagnostic output (#69)
## What changed - add a reusable, width-aware status-list renderer for diagnostic output - collapse healthy `hack doctor` groups and `hack setup sync` scopes into compact rows - expand only actionable warnings and errors with wrapped details and paths - constrain panels to readable terminal widths and preserve list indentation while wrapping - suppress raw Docker IDs and subprocess chatter during doctor repairs - show macOS resolver setup only when resolver checks need attention ## Why Diagnostic commands were mixing raw log events, wide bordered prose, subprocess output, and healthy per-artifact lines. On real terminals this produced noisy walls of text and broken borders, making the actual repair path hard to find. ## Compatibility - `hack doctor --json` remains unchanged as the detailed automation contract - setup/doctor exit-code behavior remains intact - failing integration paths remain visible; healthy paths are summarized ## Validation - `bun test` - `bun run check` - `bun run typecheck` - `bun run build` - `bun run docs:cli-reference` - live 72-column pseudo-terminal previews of `hack doctor` and `hack setup sync --all-scopes --check` ## Release signal Yes. This is a user-facing CLI improvement and uses a `feat(cli)` commit, so it should trigger the normal feature release signal.
1 parent 0320eca commit db47c68

8 files changed

Lines changed: 623 additions & 144 deletions

File tree

docs/cli.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ terminal).
2626
- `hack setup` — install/refresh agent integrations (Cursor rules, Claude hooks, Codex skill, MCP)
2727
- `hack tickets` — deprecated compatibility surface for existing Tickets data
2828

29+
Interactive diagnostics use compact status rows: healthy groups stay on one line, while warnings
30+
and errors expand with wrapped detail and recovery guidance. `hack doctor --json` remains the stable,
31+
fully detailed automation surface. Generic macOS resolver setup is shown only when those resolver
32+
checks need attention.
33+
2934
Run `hack help` for the full command list, or `hack help --all` to include hidden unsupported
3035
experimental commands. Every command and flag on this page is also in the generated
3136
[CLI reference](reference/cli.md).

docs/integrations.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ What remains:
99
- optional coding-agent setup helpers: `hack init --with claude|codex|both`, `hack agent onboard` /
1010
`hack agent init` / `hack agent prime`, and `hack setup sync --all-scopes`
1111

12+
`hack setup sync` keeps interactive output compact: it summarizes each scope and only expands the
13+
path and reason for stale, missing, or failed artifacts. Exit status remains the automation contract,
14+
and the individual `hack setup cursor|claude|codex|agents|mcp --check` commands remain available when
15+
you need per-artifact detail.
16+
1217
What was removed:
1318

1419
- Hack Tickets agent integration; legacy commands remain compatibility-only and are deprecated

src/commands/doctor.ts

Lines changed: 146 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ import {
112112
renderGlobalCaddyCompose,
113113
renderGlobalCoreDnsConfig,
114114
} from "../templates.ts";
115-
import { display } from "../ui/display.ts";
115+
import { type DisplayStatusItem, display } from "../ui/display.ts";
116116
import { getFzfPath } from "../ui/fzf.ts";
117117
import { getGumPath } from "../ui/gum.ts";
118118
import {
@@ -609,7 +609,7 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
609609

610610
await renderDoctorSummary(results);
611611
emitSlowChecksNote(results);
612-
renderMacNote(results);
612+
await renderMacNote(results);
613613
if (!args.options.fix) {
614614
await renderRecoveryGuidance(results);
615615
}
@@ -3104,11 +3104,18 @@ async function ensureIngressNetwork(): Promise<{
31043104
}
31053105

31063106
if (ingress.exists) {
3107-
await run(["docker", "network", "rm", DEFAULT_INGRESS_NETWORK], {
3108-
stdin: "inherit",
3109-
});
3107+
const removed = await exec(
3108+
["docker", "network", "rm", DEFAULT_INGRESS_NETWORK],
3109+
{
3110+
stdin: "ignore",
3111+
}
3112+
);
3113+
if (removed.exitCode !== 0) {
3114+
note(commandFailureDetail({ result: removed }), "runtime repair");
3115+
return ingress;
3116+
}
31103117
}
3111-
await run(
3118+
const created = await exec(
31123119
[
31133120
"docker",
31143121
"network",
@@ -3119,8 +3126,11 @@ async function ensureIngressNetwork(): Promise<{
31193126
"--gateway",
31203127
DEFAULT_INGRESS_GATEWAY,
31213128
],
3122-
{ stdin: "inherit" }
3129+
{ stdin: "ignore" }
31233130
);
3131+
if (created.exitCode !== 0) {
3132+
note(commandFailureDetail({ result: created }), "runtime repair");
3133+
}
31243134

31253135
return await inspectDockerNetwork(DEFAULT_INGRESS_NETWORK);
31263136
}
@@ -3131,9 +3141,15 @@ async function ensureLoggingNetwork(): Promise<void> {
31313141
return;
31323142
}
31333143

3134-
await run(["docker", "network", "create", DEFAULT_LOGGING_NETWORK], {
3135-
stdin: "inherit",
3136-
});
3144+
const created = await exec(
3145+
["docker", "network", "create", DEFAULT_LOGGING_NETWORK],
3146+
{
3147+
stdin: "ignore",
3148+
}
3149+
);
3150+
if (created.exitCode !== 0) {
3151+
note(commandFailureDetail({ result: created }), "runtime repair");
3152+
}
31373153
}
31383154

31393155
async function maybeStartGlobalCaddyCompose(opts: {
@@ -3143,7 +3159,7 @@ async function maybeStartGlobalCaddyCompose(opts: {
31433159
return;
31443160
}
31453161

3146-
await run(
3162+
const started = await exec(
31473163
[
31483164
"docker",
31493165
"compose",
@@ -3155,9 +3171,27 @@ async function maybeStartGlobalCaddyCompose(opts: {
31553171
],
31563172
{
31573173
cwd: dirname(opts.paths.caddyCompose),
3158-
stdin: "inherit",
3174+
stdin: "ignore",
31593175
}
31603176
);
3177+
if (started.exitCode !== 0) {
3178+
note(commandFailureDetail({ result: started }), "runtime repair");
3179+
return;
3180+
}
3181+
note("CoreDNS and Caddy are ready.", "runtime repair");
3182+
}
3183+
3184+
function commandFailureDetail(input: {
3185+
readonly result: {
3186+
readonly exitCode: number;
3187+
readonly stdout: string;
3188+
readonly stderr: string;
3189+
};
3190+
}): string {
3191+
const detail = input.result.stderr.trim() || input.result.stdout.trim();
3192+
return detail.length > 0
3193+
? `Repair command failed: ${detail}`
3194+
: `Repair command failed (exit ${input.result.exitCode}).`;
31613195
}
31623196

31633197
async function maybeExportCaddyCaCert(opts: {
@@ -3562,26 +3596,85 @@ function isHiddenDoctorCheck(result: RecoveryCheckResult): boolean {
35623596
return result.name === "tickets git";
35633597
}
35643598

3599+
export function buildDoctorSummaryStatusItems(input: {
3600+
readonly results: readonly RecoveryCheckResult[];
3601+
}): readonly DisplayStatusItem[] {
3602+
const items: DisplayStatusItem[] = [];
3603+
3604+
for (const group of DOCTOR_SUMMARY_GROUPS) {
3605+
const members = input.results.filter((result) =>
3606+
group.checks.has(result.name)
3607+
);
3608+
if (members.length === 0) {
3609+
continue;
3610+
}
3611+
items.push(buildDoctorSummaryStatusItem({ title: group.title, members }));
3612+
}
3613+
3614+
const ungrouped = input.results.filter(
3615+
(result) =>
3616+
!(
3617+
isHiddenDoctorCheck(result) ||
3618+
DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name))
3619+
)
3620+
);
3621+
if (ungrouped.length > 0) {
3622+
items.push(
3623+
buildDoctorSummaryStatusItem({
3624+
title: "Other checks",
3625+
members: ungrouped,
3626+
})
3627+
);
3628+
}
3629+
3630+
return items;
3631+
}
3632+
3633+
function buildDoctorSummaryStatusItem(input: {
3634+
readonly title: string;
3635+
readonly members: readonly RecoveryCheckResult[];
3636+
}): DisplayStatusItem {
3637+
const issues = input.members.filter(
3638+
(result) => result.status !== "ok" && !isIgnorableDoctorSummaryIssue(result)
3639+
);
3640+
const errorCount = issues.filter(
3641+
(result) => result.status === "error"
3642+
).length;
3643+
const warningCount = issues.length - errorCount;
3644+
let status: DisplayStatusItem["status"] = "ok";
3645+
if (errorCount > 0) {
3646+
status = "error";
3647+
} else if (warningCount > 0) {
3648+
status = "warn";
3649+
}
3650+
3651+
const issueCounts = [
3652+
errorCount > 0 ? `${errorCount} error${errorCount === 1 ? "" : "s"}` : null,
3653+
warningCount > 0
3654+
? `${warningCount} warning${warningCount === 1 ? "" : "s"}`
3655+
: null,
3656+
].filter((part): part is string => part !== null);
3657+
3658+
return {
3659+
label: input.title,
3660+
status,
3661+
meta:
3662+
issueCounts.length > 0
3663+
? issueCounts.join(", ")
3664+
: `${input.members.length} checks`,
3665+
detail:
3666+
issues.length > 0
3667+
? summarizeDoctorGroupIssues({ title: input.title, issues })
3668+
: undefined,
3669+
};
3670+
}
3671+
35653672
async function renderDoctorSummary(
35663673
results: readonly TimedCheckResult[]
35673674
): Promise<void> {
3568-
const summaryLines = buildDoctorSummaryLines({ results });
3569-
let tone: "error" | "warn" | "info" = "info";
3570-
if (results.some((result) => result.status === "error")) {
3571-
tone = "error";
3572-
} else if (
3573-
results.some(
3574-
(result) =>
3575-
result.status === "warn" && !isIgnorableDoctorSummaryIssue(result)
3576-
)
3577-
) {
3578-
tone = "warn";
3579-
}
3580-
3581-
await display.panel({
3675+
await display.statusList({
35823676
title: "Doctor summary",
3583-
tone,
3584-
lines: summaryLines,
3677+
items: buildDoctorSummaryStatusItems({ results }),
35853678
});
35863679
}
35873680

@@ -3649,23 +3742,34 @@ function summarizeDoctorIssue(issue: RecoveryCheckResult): string {
36493742
return `${issue.name}: ${issue.message}`;
36503743
}
36513744

3652-
function renderMacNote(results: readonly RecoveryCheckResult[]): void {
3653-
const dnsNeedsSetup = results.some(
3745+
async function renderMacNote(
3746+
results: readonly TimedCheckResult[]
3747+
): Promise<void> {
3748+
const hasResolverIssue = results.some(
36543749
(result) =>
3655-
DOCTOR_SUMMARY_GROUPS.find(
3656-
(group) => group.title === "Resolver & DNS"
3657-
)?.checks.has(result.name) && result.status !== "ok"
3750+
result.status !== "ok" &&
3751+
(result.name.startsWith("resolver:") ||
3752+
result.name.startsWith("dnsmasq.conf:"))
36583753
);
3659-
if (isMac() && dnsNeedsSetup) {
3660-
note(
3661-
[
3662-
"macOS tip:",
3663-
`- wildcard DNS: /etc/resolver/${DEFAULT_PROJECT_TLD} + dnsmasq address=/.${DEFAULT_PROJECT_TLD}/<reachable-ingress-target>`,
3664-
`- OAuth alias DNS: /etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT} + dnsmasq address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/<reachable-ingress-target>`,
3665-
].join("\n"),
3666-
"doctor"
3667-
);
3754+
if (!(isMac() && hasResolverIssue)) {
3755+
return;
36683756
}
3757+
3758+
await display.statusList({
3759+
title: "macOS resolver setup",
3760+
items: [
3761+
{
3762+
label: `*.${DEFAULT_PROJECT_TLD}`,
3763+
status: "info",
3764+
detail: `/etc/resolver/${DEFAULT_PROJECT_TLD} with dnsmasq address=/.${DEFAULT_PROJECT_TLD}/<reachable-ingress-target>`,
3765+
},
3766+
{
3767+
label: `*.${DEFAULT_OAUTH_ALIAS_ROOT}`,
3768+
status: "info",
3769+
detail: `/etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT} with dnsmasq address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/<reachable-ingress-target>`,
3770+
},
3771+
],
3772+
});
36693773
}
36703774

36713775
async function resolvePreferredMacHostDnsTarget(): Promise<string> {

0 commit comments

Comments
 (0)