@onebrain-ai/cli 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@onebrain-ai/cli",
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "onebrain": "dist/onebrain"
7
+ },
8
+ "scripts": {
9
+ "build": "bun build src/index.ts --outfile dist/onebrain --target node",
10
+ "typecheck": "tsc --noEmit",
11
+ "bump": "echo 'bump not yet implemented'"
12
+ },
13
+ "dependencies": {
14
+ "@onebrain/core": "workspace:*",
15
+ "@clack/prompts": "^0.9",
16
+ "commander": "^12",
17
+ "yaml": "^2"
18
+ },
19
+ "devDependencies": {
20
+ "@types/bun": "latest",
21
+ "@types/node": "^20"
22
+ }
23
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Tests for `onebrain doctor` — runDoctor()
3
+ *
4
+ * All @onebrain/core validators are injected via opts so tests are
5
+ * fast, offline, and deterministic. No mock.module needed.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
9
+ import { mkdir, rm } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import type { VaultConfig } from '@onebrain/core';
14
+ import { type DoctorOptions, runDoctor } from './doctor.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ async function makeTempVault(): Promise<string> {
21
+ const dir = join(
22
+ tmpdir(),
23
+ `onebrain-doctor-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
24
+ );
25
+ await mkdir(dir, { recursive: true });
26
+ return dir;
27
+ }
28
+
29
+ const DEFAULT_CONFIG: VaultConfig = {
30
+ folders: {
31
+ inbox: '00-inbox',
32
+ projects: '01-projects',
33
+ areas: '02-areas',
34
+ knowledge: '03-knowledge',
35
+ resources: '04-resources',
36
+ agent: '05-agent',
37
+ archive: '06-archive',
38
+ logs: '07-logs',
39
+ },
40
+ };
41
+
42
+ function makeAllOkValidators(): Required<
43
+ Pick<
44
+ DoctorOptions,
45
+ | 'checkVaultYmlFn'
46
+ | 'loadVaultConfigFn'
47
+ | 'checkFoldersFn'
48
+ | 'checkHarnessBinaryFn'
49
+ | 'checkQmdEmbeddingsFn'
50
+ | 'checkVersionDriftFn'
51
+ | 'checkOrphanCheckpointsFn'
52
+ | 'checkSandboxFn'
53
+ >
54
+ > {
55
+ return {
56
+ checkVaultYmlFn: async () => ({ check: 'vault.yml', status: 'ok', message: 'valid' }),
57
+ loadVaultConfigFn: async () => DEFAULT_CONFIG,
58
+ checkFoldersFn: async () => ({ check: 'folders', status: 'ok', message: '8/8 present' }),
59
+ checkHarnessBinaryFn: async () => ({
60
+ check: 'runtime.harness',
61
+ status: 'ok',
62
+ message: 'claude-code (found)',
63
+ }),
64
+ checkQmdEmbeddingsFn: async () => ({
65
+ check: 'qmd-embeddings',
66
+ status: 'ok',
67
+ message: 'all embedded',
68
+ }),
69
+ checkVersionDriftFn: async () => ({ check: 'version-drift', status: 'ok', message: 'v1.0.0' }),
70
+ checkOrphanCheckpointsFn: async () => ({
71
+ check: 'orphan-checkpoints',
72
+ status: 'ok',
73
+ message: '0 orphans',
74
+ }),
75
+ checkSandboxFn: async () => ({ check: 'sandbox', status: 'ok', message: 'enabled' }),
76
+ };
77
+ }
78
+
79
+ let tempDir: string;
80
+
81
+ beforeEach(async () => {
82
+ tempDir = await makeTempVault();
83
+ });
84
+
85
+ afterEach(async () => {
86
+ await rm(tempDir, { recursive: true, force: true });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Tests
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe('runDoctor', () => {
94
+ // ── Exit codes ─────────────────────────────────────────────────────────────
95
+
96
+ describe('exit codes', () => {
97
+ it('returns exitCode 1 when any check returns status error', async () => {
98
+ const validators = makeAllOkValidators();
99
+ validators.checkVaultYmlFn = async () => ({
100
+ check: 'vault.yml',
101
+ status: 'error',
102
+ message: 'not found',
103
+ });
104
+
105
+ const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
106
+
107
+ expect(result.exitCode).toBe(1);
108
+ expect(result.ok).toBe(false);
109
+ expect(result.errorCount).toBeGreaterThanOrEqual(1);
110
+ });
111
+
112
+ it('returns exitCode 0 when checks return only warnings (no errors)', async () => {
113
+ const validators = makeAllOkValidators();
114
+ validators.checkFoldersFn = async () => ({
115
+ check: 'folders',
116
+ status: 'warn',
117
+ message: '7/8 present',
118
+ });
119
+ validators.checkSandboxFn = async () => ({
120
+ check: 'sandbox',
121
+ status: 'warn',
122
+ message: 'disabled',
123
+ });
124
+
125
+ const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
126
+
127
+ expect(result.exitCode).toBe(0);
128
+ expect(result.ok).toBe(true);
129
+ expect(result.warningCount).toBeGreaterThanOrEqual(2);
130
+ expect(result.errorCount).toBe(0);
131
+ });
132
+
133
+ it('returns exitCode 0 when all checks pass', async () => {
134
+ const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
135
+
136
+ expect(result.exitCode).toBe(0);
137
+ expect(result.ok).toBe(true);
138
+ expect(result.errorCount).toBe(0);
139
+ expect(result.warningCount).toBe(0);
140
+ });
141
+ });
142
+
143
+ // ── binaryVersion forwarding ───────────────────────────────────────────────
144
+
145
+ describe('binaryVersion forwarding', () => {
146
+ it('forwards binaryVersion to checkVersionDriftFn when provided', async () => {
147
+ let capturedBinaryVersion: string | undefined = 'not-set';
148
+ const validators = makeAllOkValidators();
149
+ validators.checkVersionDriftFn = async (_vaultDir, _config, bv) => {
150
+ capturedBinaryVersion = bv;
151
+ return { check: 'version-drift', status: 'ok', message: 'ok' };
152
+ };
153
+
154
+ await runDoctor({ vaultDir: tempDir, isTTY: false, binaryVersion: 'v2.0.0', ...validators });
155
+
156
+ expect(capturedBinaryVersion).toBe('v2.0.0');
157
+ });
158
+
159
+ it('passes undefined binaryVersion to checkVersionDriftFn when omitted', async () => {
160
+ let capturedBinaryVersion: string | undefined = 'not-set';
161
+ const validators = makeAllOkValidators();
162
+ validators.checkVersionDriftFn = async (_vaultDir, _config, bv) => {
163
+ capturedBinaryVersion = bv;
164
+ return { check: 'version-drift', status: 'ok', message: 'ok' };
165
+ };
166
+
167
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
168
+
169
+ expect(capturedBinaryVersion).toBeUndefined();
170
+ });
171
+ });
172
+
173
+ // ── Summary line selection ─────────────────────────────────────────────────
174
+
175
+ describe('summary line selection', () => {
176
+ it('shows "N errors, N warnings" when both errors and warnings exist', async () => {
177
+ const logLines: string[] = [];
178
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
179
+ logLines.push(msg);
180
+ });
181
+ try {
182
+ const validators = makeAllOkValidators();
183
+ validators.checkVaultYmlFn = async () => ({
184
+ check: 'vault.yml',
185
+ status: 'error',
186
+ message: 'not found',
187
+ });
188
+ validators.checkFoldersFn = async () => ({
189
+ check: 'folders',
190
+ status: 'warn',
191
+ message: '7/8 present',
192
+ });
193
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
194
+ } finally {
195
+ spy.mockRestore();
196
+ }
197
+
198
+ expect(logLines.join('\n')).toMatch(/Summary: 1 errors, 1 warnings/);
199
+ });
200
+
201
+ it('shows "N errors" (no warnings mention) when only errors', async () => {
202
+ const logLines: string[] = [];
203
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
204
+ logLines.push(msg);
205
+ });
206
+ try {
207
+ const validators = makeAllOkValidators();
208
+ validators.checkVaultYmlFn = async () => ({
209
+ check: 'vault.yml',
210
+ status: 'error',
211
+ message: 'not found',
212
+ });
213
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
214
+ } finally {
215
+ spy.mockRestore();
216
+ }
217
+
218
+ const output = logLines.join('\n');
219
+ expect(output).toMatch(/Summary: 1 errors$/m);
220
+ expect(output).not.toMatch(/warnings/);
221
+ });
222
+
223
+ it('shows "N warnings — ok to run" when only warnings (no errors)', async () => {
224
+ const logLines: string[] = [];
225
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
226
+ logLines.push(msg);
227
+ });
228
+ try {
229
+ const validators = makeAllOkValidators();
230
+ validators.checkSandboxFn = async () => ({
231
+ check: 'sandbox',
232
+ status: 'warn',
233
+ message: 'disabled',
234
+ });
235
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
236
+ } finally {
237
+ spy.mockRestore();
238
+ }
239
+
240
+ expect(logLines.join('\n')).toMatch(/Summary: 1 warnings — ok to run/);
241
+ });
242
+
243
+ it('shows "All checks passed" when no errors or warnings', async () => {
244
+ const logLines: string[] = [];
245
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
246
+ logLines.push(msg);
247
+ });
248
+ try {
249
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
250
+ } finally {
251
+ spy.mockRestore();
252
+ }
253
+
254
+ expect(logLines.join('\n')).toMatch(/Summary: All checks passed/);
255
+ });
256
+ });
257
+
258
+ // ── TTY vs non-TTY output formatting ──────────────────────────────────────
259
+
260
+ describe('TTY vs non-TTY output', () => {
261
+ it('non-TTY: plain title without leading blank line', async () => {
262
+ const logLines: string[] = [];
263
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
264
+ logLines.push(msg);
265
+ });
266
+ try {
267
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
268
+ } finally {
269
+ spy.mockRestore();
270
+ }
271
+
272
+ expect(logLines.join('\n')).toMatch(/^OneBrain Doctor 🔍/);
273
+ });
274
+
275
+ it('TTY: title is padded with surrounding blank lines', async () => {
276
+ const logLines: string[] = [];
277
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
278
+ logLines.push(msg);
279
+ });
280
+ try {
281
+ await runDoctor({ vaultDir: tempDir, isTTY: true, ...makeAllOkValidators() });
282
+ } finally {
283
+ spy.mockRestore();
284
+ }
285
+
286
+ const output = logLines.join('\n');
287
+ expect(output).toMatch(/^\n\s+OneBrain Doctor 🔍/);
288
+ expect(output).toMatch(/Summary: All checks passed\n$/);
289
+ });
290
+ });
291
+
292
+ // ── loadVaultConfig failure resilience ────────────────────────────────────
293
+
294
+ describe('loadVaultConfig failure resilience', () => {
295
+ it('continues with default config when loadVaultConfigFn throws after valid vault.yml', async () => {
296
+ let foldersConfigReceived: VaultConfig | undefined;
297
+ const validators = makeAllOkValidators();
298
+ validators.checkVaultYmlFn = async () => ({
299
+ check: 'vault.yml',
300
+ status: 'ok',
301
+ message: 'valid',
302
+ });
303
+ validators.loadVaultConfigFn = async () => {
304
+ throw new Error('parse error');
305
+ };
306
+ validators.checkFoldersFn = async (_vaultDir, config) => {
307
+ foldersConfigReceived = config;
308
+ return { check: 'folders', status: 'ok', message: '8/8 present' };
309
+ };
310
+
311
+ const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
312
+
313
+ expect(result.ok).toBe(true);
314
+ expect(result.exitCode).toBe(0);
315
+ expect(foldersConfigReceived?.folders.inbox).toBe('00-inbox');
316
+ expect(foldersConfigReceived?.folders.logs).toBe('07-logs');
317
+ });
318
+
319
+ it('skips loadVaultConfigFn when checkVaultYml returns error', async () => {
320
+ let loadCalled = false;
321
+ const validators = makeAllOkValidators();
322
+ validators.checkVaultYmlFn = async () => ({
323
+ check: 'vault.yml',
324
+ status: 'error',
325
+ message: 'not found',
326
+ });
327
+ validators.loadVaultConfigFn = async () => {
328
+ loadCalled = true;
329
+ return DEFAULT_CONFIG;
330
+ };
331
+
332
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
333
+
334
+ expect(loadCalled).toBe(false);
335
+ });
336
+ });
337
+
338
+ // ── Hint lines ────────────────────────────────────────────────────────────
339
+
340
+ describe('hint lines', () => {
341
+ it('includes hint line in output when a check returns a hint', async () => {
342
+ const logLines: string[] = [];
343
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
344
+ logLines.push(msg);
345
+ });
346
+ try {
347
+ const validators = makeAllOkValidators();
348
+ validators.checkVaultYmlFn = async () => ({
349
+ check: 'vault.yml',
350
+ status: 'error',
351
+ message: 'vault.yml not found',
352
+ hint: 'Run onebrain init to create vault.yml',
353
+ });
354
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
355
+ } finally {
356
+ spy.mockRestore();
357
+ }
358
+
359
+ expect(logLines.join('\n')).toContain('→ Run onebrain init to create vault.yml');
360
+ });
361
+
362
+ it('does not include a hint line when check has no hint', async () => {
363
+ const logLines: string[] = [];
364
+ const spy = spyOn(console, 'log').mockImplementation((msg: string) => {
365
+ logLines.push(msg);
366
+ });
367
+ try {
368
+ await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
369
+ } finally {
370
+ spy.mockRestore();
371
+ }
372
+
373
+ expect(logLines.join('\n')).not.toContain('→');
374
+ });
375
+ });
376
+
377
+ // ── errorCount / warningCount accuracy ────────────────────────────────────
378
+
379
+ describe('result counts', () => {
380
+ it('accurately counts multiple errors and warnings across all checks', async () => {
381
+ const validators = makeAllOkValidators();
382
+ validators.checkVaultYmlFn = async () => ({
383
+ check: 'vault.yml',
384
+ status: 'error',
385
+ message: 'not found',
386
+ });
387
+ validators.checkFoldersFn = async () => ({
388
+ check: 'folders',
389
+ status: 'error',
390
+ message: '0/8 present',
391
+ });
392
+ validators.checkSandboxFn = async () => ({
393
+ check: 'sandbox',
394
+ status: 'warn',
395
+ message: 'disabled',
396
+ });
397
+ validators.checkHarnessBinaryFn = async () => ({
398
+ check: 'runtime.harness',
399
+ status: 'warn',
400
+ message: 'not found',
401
+ });
402
+
403
+ const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...validators });
404
+
405
+ expect(result.errorCount).toBe(2);
406
+ expect(result.warningCount).toBe(2);
407
+ expect(result.exitCode).toBe(1);
408
+ });
409
+
410
+ it('returns errorCount 0 and warningCount 0 when all checks pass', async () => {
411
+ const result = await runDoctor({ vaultDir: tempDir, isTTY: false, ...makeAllOkValidators() });
412
+ expect(result.errorCount).toBe(0);
413
+ expect(result.warningCount).toBe(0);
414
+ });
415
+ });
416
+ });
@@ -0,0 +1,203 @@
1
+ import {
2
+ type DoctorResult,
3
+ type VaultConfig,
4
+ checkFolders,
5
+ checkHarnessBinary,
6
+ checkOrphanCheckpoints,
7
+ checkQmdEmbeddings,
8
+ checkSandbox,
9
+ checkVaultYml,
10
+ checkVersionDrift,
11
+ loadVaultConfig,
12
+ } from '@onebrain/core';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface DoctorOptions {
19
+ /** Vault root directory (default: process.cwd()). */
20
+ vaultDir?: string;
21
+ /** Whether stdout is a TTY (default: process.stdout.isTTY). */
22
+ isTTY?: boolean;
23
+ /** Compiled binary version (BUILD_VERSION). When provided, compared against plugin.json instead of vault.yml onebrain_version. */
24
+ binaryVersion?: string;
25
+ /** Injectable validators — real implementations are used when absent. */
26
+ checkVaultYmlFn?: (vaultDir: string) => Promise<DoctorResult>;
27
+ loadVaultConfigFn?: (vaultDir: string) => Promise<VaultConfig>;
28
+ checkFoldersFn?: (vaultDir: string, config: VaultConfig) => Promise<DoctorResult>;
29
+ checkHarnessBinaryFn?: (config: VaultConfig) => Promise<DoctorResult>;
30
+ checkQmdEmbeddingsFn?: (config: VaultConfig) => Promise<DoctorResult>;
31
+ checkVersionDriftFn?: (
32
+ vaultDir: string,
33
+ config: VaultConfig,
34
+ binaryVersion?: string,
35
+ ) => Promise<DoctorResult>;
36
+ checkOrphanCheckpointsFn?: (vaultDir: string, config: VaultConfig) => Promise<DoctorResult>;
37
+ checkSandboxFn?: (config: VaultConfig) => DoctorResult | Promise<DoctorResult>;
38
+ }
39
+
40
+ export interface DoctorCommandResult {
41
+ ok: boolean;
42
+ exitCode: number;
43
+ errorCount: number;
44
+ warningCount: number;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Main runDoctor (pure, testable)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorCommandResult> {
52
+ const vaultDir = opts.vaultDir ?? process.cwd();
53
+ const isTTY = opts.isTTY ?? process.stdout.isTTY ?? false;
54
+ const binaryVersion = opts.binaryVersion;
55
+
56
+ const checkVaultYmlFn = opts.checkVaultYmlFn ?? checkVaultYml;
57
+ const loadVaultConfigFn = opts.loadVaultConfigFn ?? loadVaultConfig;
58
+ const checkFoldersFn = opts.checkFoldersFn ?? checkFolders;
59
+ const checkHarnessBinaryFn = opts.checkHarnessBinaryFn ?? checkHarnessBinary;
60
+ const checkQmdEmbeddingsFn = opts.checkQmdEmbeddingsFn ?? checkQmdEmbeddings;
61
+ const checkVersionDriftFn = opts.checkVersionDriftFn ?? checkVersionDrift;
62
+ const checkOrphanCheckpointsFn = opts.checkOrphanCheckpointsFn ?? checkOrphanCheckpoints;
63
+ const checkSandboxFn = opts.checkSandboxFn ?? checkSandbox;
64
+
65
+ const vaultYmlResult = await checkVaultYmlFn(vaultDir);
66
+
67
+ let config: VaultConfig = {
68
+ folders: {
69
+ inbox: '00-inbox',
70
+ projects: '01-projects',
71
+ areas: '02-areas',
72
+ knowledge: '03-knowledge',
73
+ resources: '04-resources',
74
+ agent: '05-agent',
75
+ archive: '06-archive',
76
+ logs: '07-logs',
77
+ },
78
+ };
79
+
80
+ if (vaultYmlResult.status === 'ok') {
81
+ try {
82
+ config = await loadVaultConfigFn(vaultDir);
83
+ } catch {
84
+ // If loading fails, use default config above
85
+ }
86
+ }
87
+
88
+ const [
89
+ foldersResult,
90
+ harnessResult,
91
+ qmdResult,
92
+ versionDriftResult,
93
+ orphanCheckpointsResult,
94
+ sandboxResult,
95
+ ] = await Promise.all([
96
+ checkFoldersFn(vaultDir, config),
97
+ checkHarnessBinaryFn(config),
98
+ checkQmdEmbeddingsFn(config),
99
+ checkVersionDriftFn(vaultDir, config, binaryVersion),
100
+ checkOrphanCheckpointsFn(vaultDir, config),
101
+ checkSandboxFn(config),
102
+ ]);
103
+
104
+ const results = [
105
+ vaultYmlResult,
106
+ foldersResult,
107
+ harnessResult,
108
+ qmdResult,
109
+ versionDriftResult,
110
+ orphanCheckpointsResult,
111
+ sandboxResult,
112
+ ];
113
+
114
+ const errorCount = results.filter((r) => r.status === 'error').length;
115
+ const warningCount = results.filter((r) => r.status === 'warn').length;
116
+
117
+ printDoctorOutput(results, isTTY, errorCount, warningCount);
118
+
119
+ return {
120
+ ok: errorCount === 0,
121
+ exitCode: errorCount > 0 ? 1 : 0,
122
+ errorCount,
123
+ warningCount,
124
+ };
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // CLI entry point — thin wrapper, calls process.exit
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export async function doctorCommand(opts: DoctorOptions = {}): Promise<void> {
132
+ const result = await runDoctor(opts);
133
+ process.exit(result.exitCode);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Formatting
138
+ // ---------------------------------------------------------------------------
139
+
140
+ function printDoctorOutput(
141
+ results: DoctorResult[],
142
+ isTTY: boolean,
143
+ errorCount: number,
144
+ warningCount: number,
145
+ ): void {
146
+ const lines: string[] = [];
147
+
148
+ if (isTTY) {
149
+ lines.push('');
150
+ lines.push(' OneBrain Doctor 🔍');
151
+ lines.push('');
152
+ } else {
153
+ lines.push('OneBrain Doctor 🔍');
154
+ lines.push('');
155
+ }
156
+
157
+ for (const result of results) {
158
+ const statusIcon = getStatusIcon(result.status);
159
+ lines.push(formatCheckLine(result, statusIcon));
160
+ if (result.hint) {
161
+ lines.push(formatHintLine(result.hint));
162
+ }
163
+ }
164
+
165
+ lines.push('');
166
+
167
+ if (errorCount > 0 && warningCount > 0) {
168
+ lines.push(`Summary: ${errorCount} errors, ${warningCount} warnings`);
169
+ } else if (errorCount > 0) {
170
+ lines.push(`Summary: ${errorCount} errors`);
171
+ } else if (warningCount > 0) {
172
+ lines.push(`Summary: ${warningCount} warnings — ok to run`);
173
+ } else {
174
+ lines.push('Summary: All checks passed');
175
+ }
176
+
177
+ if (isTTY) {
178
+ lines.push('');
179
+ }
180
+
181
+ console.log(lines.join('\n'));
182
+ }
183
+
184
+ function getStatusIcon(status: 'ok' | 'warn' | 'error'): string {
185
+ switch (status) {
186
+ case 'ok':
187
+ return '[✓]';
188
+ case 'warn':
189
+ return '[!]';
190
+ case 'error':
191
+ return '[✗]';
192
+ default:
193
+ return '[?]';
194
+ }
195
+ }
196
+
197
+ function formatCheckLine(result: DoctorResult, icon: string): string {
198
+ return ` ${icon} ${result.check.padEnd(20)} ${result.message}`;
199
+ }
200
+
201
+ function formatHintLine(hint: string): string {
202
+ return ` → ${hint}`;
203
+ }