Skip to content

Commit 989f5d1

Browse files
committed
feat(cli): modernize CLI output with compact, scannable format
Following UX best practices for developer CLI tools to improve DX: ## New Output System Created packages/cli/src/utils/output.ts: - Clean output functions without logger timestamps - Compact, scannable formatters for all commands - Progressive disclosure (compact by default, --verbose for details) - Follows patterns from gh CLI, npm, git ## Updated Commands **stats**: One-line summary + optional verbose tables Before: 20+ lines with [timestamp] INFO prefixes After: 📊 dev-agent • 212 files • 2,205 components • Indexed 10m ago • ✓ Healthy **index**: Compact success summary Before: Multi-section breakdown with timestamps After: 📊 Indexed: 1 files • 3 components • Duration: 0.11s • Storage: 11 KB **update**: Single-line status Before: Section headers with detailed stats After: ✓ Updated 6 files • 19 components • 3.19s **search**: Scannable results (compact or verbose) Compact: One line per result with score and location Verbose: Multi-line with signatures and docs (--verbose flag) **gh index**: Compact GitHub summary After: ✓ Indexed 155 GitHub documents • 67 issues • 88 PRs • 7.44s **init**: Clean welcome message Before: [timestamp] INFO for each line After: Clean bullet points with next steps ## Test Improvements Suppressed intentional error logs in tests: - ExplorerAgent error handling tests - Coordinator integration tests - GitHub agent error tests - Config loading error tests Prevents test output pollution while maintaining error validation. ## Benefits 1. **Glanceable** - Key metrics visible at a glance 2. **Respects terminal space** - No unnecessary scrolling 3. **Actionable** - Shows next steps when needed 4. **Familiar** - Matches conventions from popular CLI tools 5. **Progressive disclosure** - --verbose flag for detailed output ## Testing - Updated commands.test.ts to mock console.log - All 1,739 tests passing ✅ - Clean test output (error logs only where intended) Addresses user feedback on "awful" logger timestamps cluttering CLI output.
1 parent 900d259 commit 989f5d1

File tree

13 files changed

+540
-170
lines changed

13 files changed

+540
-170
lines changed

packages/cli/src/commands/commands.test.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,11 @@ export class Calculator {
100100
}`
101101
);
102102

103-
// Capture logger output
103+
// Capture console output (used by output.log)
104104
const loggedMessages: string[] = [];
105-
const loggerModule = await import('../utils/logger.js');
106-
const originalLog = loggerModule.logger.log;
107-
vi.spyOn(loggerModule.logger, 'log').mockImplementation((msg: string) => {
108-
loggedMessages.push(msg);
105+
const originalConsoleLog = console.log;
106+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
107+
loggedMessages.push(args.join(' '));
109108
});
110109

111110
// Mock process.exit to prevent test termination
@@ -119,12 +118,15 @@ export class Calculator {
119118
await program.parseAsync(['node', 'cli', 'index', indexDir, '--no-git', '--no-github']);
120119

121120
exitSpy.mockRestore();
122-
loggerModule.logger.log = originalLog;
121+
console.log = originalConsoleLog;
123122

124-
// Verify storage size is in the output
125-
const storageSizeLog = loggedMessages.find((msg) => msg.includes('Storage size:'));
123+
// Verify storage size is in the output (new compact format shows it after duration)
124+
const storageSizeLog = loggedMessages.find(
125+
(msg) => msg.includes('Duration:') || msg.includes('Storage:')
126+
);
126127
expect(storageSizeLog).toBeDefined();
127-
expect(storageSizeLog).toMatch(/Storage size:.*\d+(\.\d+)?\s*(B|KB|MB|GB)/);
128+
// Check for storage size in compact format: "Duration: X • Storage: Y"
129+
expect(loggedMessages.some((msg) => /\d+(\.\d+)?\s*(B|KB|MB|GB)/.test(msg))).toBe(true);
128130
}, 30000); // 30s timeout for indexing
129131
});
130132
});

packages/cli/src/commands/gh.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { createLogger } from '@lytics/kero';
99
import chalk from 'chalk';
1010
import { Command } from 'commander';
1111
import ora from 'ora';
12+
import { formatNumber } from '../utils/formatters.js';
1213
import { keroLogger, logger } from '../utils/logger.js';
14+
import { output } from '../utils/output.js';
1315

1416
/**
1517
* Create GitHub indexer with centralized storage
@@ -99,23 +101,18 @@ export const ghCommand = new Command('gh')
99101
},
100102
});
101103

102-
spinner.succeed(chalk.green('GitHub data indexed!'));
104+
spinner.stop();
103105

104-
// Display stats
105-
logger.log('');
106-
logger.log(chalk.bold('Indexing Stats:'));
107-
logger.log(` Repository: ${chalk.cyan(stats.repository)}`);
108-
logger.log(` Total: ${chalk.yellow(stats.totalDocuments)} documents`);
106+
// Compact summary
107+
const issues = stats.byType.issue || 0;
108+
const prs = stats.byType.pull_request || 0;
109+
const duration = (stats.indexDuration / 1000).toFixed(2);
109110

110-
if (stats.byType.issue) {
111-
logger.log(` Issues: ${stats.byType.issue}`);
112-
}
113-
if (stats.byType.pull_request) {
114-
logger.log(` Pull Requests: ${stats.byType.pull_request}`);
115-
}
116-
117-
logger.log(` Duration: ${stats.indexDuration}ms`);
118-
logger.log('');
111+
output.log('');
112+
output.success(`Indexed ${formatNumber(stats.totalDocuments)} GitHub documents`);
113+
output.log(` ${chalk.gray('Repository:')} ${chalk.bold(stats.repository)}`);
114+
output.log(` ${issues} issues • ${prs} PRs • ${duration}s`);
115+
output.log('');
119116
} catch (error) {
120117
spinner.fail('Indexing failed');
121118
logger.error((error as Error).message);

packages/cli/src/commands/index.ts

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import ora from 'ora';
1818
import { getDefaultConfig, loadConfig } from '../utils/config.js';
1919
import { formatBytes, getDirectorySize } from '../utils/file.js';
2020
import { createIndexLogger, logger } from '../utils/logger.js';
21+
import { formatIndexSummary, output } from '../utils/output.js';
2122

2223
/**
2324
* Check if a command is available
@@ -165,20 +166,10 @@ export const indexCommand = new Command('index')
165166

166167
await indexer.close();
167168

168-
const codeDuration = ((Date.now() - startTime) / 1000).toFixed(2);
169+
const codeDuration = (Date.now() - startTime) / 1000;
169170

170171
spinner.succeed(chalk.green('Code indexed successfully!'));
171172

172-
// Show code stats
173-
logger.log('');
174-
logger.log(chalk.bold('Code Indexing:'));
175-
logger.log(` ${chalk.cyan('Files scanned:')} ${stats.filesScanned}`);
176-
logger.log(` ${chalk.cyan('Documents extracted:')} ${stats.documentsExtracted}`);
177-
logger.log(` ${chalk.cyan('Documents indexed:')} ${stats.documentsIndexed}`);
178-
logger.log(` ${chalk.cyan('Vectors stored:')} ${stats.vectorsStored}`);
179-
logger.log(` ${chalk.cyan('Storage size:')} ${formatBytes(storageSize)}`);
180-
logger.log(` ${chalk.cyan('Duration:')} ${codeDuration}s`);
181-
182173
// Index git history if available
183174
let gitStats = { commitsIndexed: 0, durationMs: 0 };
184175
if (canIndexGit) {
@@ -206,12 +197,6 @@ export const indexCommand = new Command('index')
206197
await gitVectorStore.close();
207198

208199
spinner.succeed(chalk.green('Git history indexed!'));
209-
logger.log('');
210-
logger.log(chalk.bold('Git History:'));
211-
logger.log(` ${chalk.cyan('Commits indexed:')} ${gitStats.commitsIndexed}`);
212-
logger.log(
213-
` ${chalk.cyan('Duration:')} ${(gitStats.durationMs / 1000).toFixed(2)}s`
214-
);
215200
}
216201

217202
// Index GitHub issues/PRs if available
@@ -238,33 +223,50 @@ export const indexCommand = new Command('index')
238223
},
239224
});
240225
spinner.succeed(chalk.green('GitHub indexed!'));
241-
logger.log('');
242-
logger.log(chalk.bold('GitHub:'));
243-
logger.log(` ${chalk.cyan('Issues/PRs indexed:')} ${ghStats.totalDocuments}`);
244-
logger.log(
245-
` ${chalk.cyan('Duration:')} ${(ghStats.indexDuration / 1000).toFixed(2)}s`
246-
);
247226
}
248227

249-
const totalDuration = ((Date.now() - startTime) / 1000).toFixed(2);
250-
251-
logger.log('');
252-
logger.log(chalk.bold('Summary:'));
253-
logger.log(` ${chalk.cyan('Total duration:')} ${totalDuration}s`);
254-
logger.log(` ${chalk.cyan('Storage:')} ${storagePath}`);
228+
const totalDuration = (Date.now() - startTime) / 1000;
229+
230+
// Compact summary output
231+
output.log('');
232+
output.log(
233+
formatIndexSummary({
234+
code: {
235+
files: stats.filesScanned,
236+
documents: stats.documentsIndexed,
237+
vectors: stats.vectorsStored,
238+
duration: codeDuration,
239+
size: formatBytes(storageSize),
240+
},
241+
git: canIndexGit
242+
? { commits: gitStats.commitsIndexed, duration: gitStats.durationMs / 1000 }
243+
: undefined,
244+
github: canIndexGitHub
245+
? { documents: ghStats.totalDocuments, duration: ghStats.indexDuration / 1000 }
246+
: undefined,
247+
total: {
248+
duration: totalDuration,
249+
storage: storagePath,
250+
},
251+
})
252+
);
255253

254+
// Show errors if any
256255
if (stats.errors.length > 0) {
257-
logger.log('');
258-
logger.warn(`${stats.errors.length} error(s) occurred during indexing`);
256+
output.log('');
257+
output.warn(`${stats.errors.length} error(s) occurred during indexing`);
259258
if (options.verbose) {
260259
for (const error of stats.errors) {
261-
logger.error(` ${error.file}: ${error.message}`);
260+
output.log(` ${chalk.gray(error.file)}: ${error.message}`);
262261
}
262+
} else {
263+
output.log(
264+
` ${chalk.gray('Run with')} ${chalk.cyan('--verbose')} ${chalk.gray('to see details')}`
265+
);
263266
}
264267
}
265268

266-
logger.log('');
267-
logger.log(`Now you can search with: ${chalk.yellow('dev search "<query>"')}`);
269+
output.log('');
268270
} catch (error) {
269271
spinner.fail('Failed to index repository');
270272
logger.error(error instanceof Error ? error.message : String(error));

packages/cli/src/commands/init.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import chalk from 'chalk';
22
import { Command } from 'commander';
33
import ora from 'ora';
44
import { getDefaultConfig, saveConfig } from '../utils/config.js';
5-
import { logger } from '../utils/logger.js';
5+
import { output } from '../utils/output.js';
66

77
export const initCommand = new Command('init')
88
.description('Initialize dev-agent in the current directory')
@@ -16,19 +16,23 @@ export const initCommand = new Command('init')
1616
spinner.text = 'Creating configuration file...';
1717
await saveConfig(config, options.path);
1818

19-
spinner.succeed(chalk.green('Dev-agent initialized successfully!'));
19+
spinner.stop();
2020

21-
logger.log('');
22-
logger.log(chalk.bold('Next steps:'));
23-
logger.log(` ${chalk.cyan('1.')} Run ${chalk.yellow('dev index')} to index your repository`);
24-
logger.log(
25-
` ${chalk.cyan('2.')} Run ${chalk.yellow('dev search "<query>"')} to search your code`
21+
// Clean output without timestamps
22+
output.log('');
23+
output.success('Initialized dev-agent');
24+
output.log('');
25+
output.log(chalk.bold('Next steps:'));
26+
output.log(` ${chalk.cyan('1.')} Run ${chalk.cyan('dev index')} to index your repository`);
27+
output.log(
28+
` ${chalk.cyan('2.')} Run ${chalk.cyan('dev search "<query>"')} to search your code`
2629
);
27-
logger.log('');
28-
logger.log(`Configuration saved to ${chalk.cyan('.dev-agent.json')}`);
30+
output.log('');
31+
output.log(` ${chalk.gray('Config saved:')} ${chalk.cyan('.dev-agent.json')}`);
32+
output.log('');
2933
} catch (error) {
3034
spinner.fail('Failed to initialize dev-agent');
31-
logger.error(error instanceof Error ? error.message : String(error));
35+
output.error(error instanceof Error ? error.message : String(error));
3236
process.exit(1);
3337
}
3438
});

packages/cli/src/commands/search.ts

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import { Command } from 'commander';
1010
import ora from 'ora';
1111
import { loadConfig } from '../utils/config.js';
1212
import { logger } from '../utils/logger.js';
13+
import { formatSearchResults, output } from '../utils/output.js';
1314

1415
export const searchCommand = new Command('search')
1516
.description('Search indexed code semantically')
1617
.argument('<query>', 'Search query')
1718
.option('-l, --limit <number>', 'Maximum number of results', '10')
1819
.option('-t, --threshold <number>', 'Minimum similarity score (0-1)', '0.7')
1920
.option('--json', 'Output results as JSON', false)
21+
.option('-v, --verbose', 'Show detailed results with signatures and docs', false)
2022
.action(async (query: string, options) => {
2123
const spinner = ora('Searching...').start();
2224

@@ -59,14 +61,15 @@ export const searchCommand = new Command('search')
5961

6062
await indexer.close();
6163

62-
spinner.succeed(chalk.green(`Found ${results.length} result(s)`));
64+
spinner.stop();
6365

6466
if (results.length === 0) {
65-
logger.log('');
66-
logger.warn('No results found. Try:');
67-
logger.log(` - Lowering the threshold: ${chalk.yellow('--threshold 0.5')}`);
68-
logger.log(` - Using different keywords`);
69-
logger.log(` - Running ${chalk.yellow('dev update')} to refresh the index`);
67+
output.log('');
68+
output.warn('No results found. Try:');
69+
output.log(` • Lower threshold: ${chalk.cyan('--threshold 0.5')}`);
70+
output.log(` • Different keywords`);
71+
output.log(` • Refresh index: ${chalk.cyan('dev update')}`);
72+
output.log('');
7073
return;
7174
}
7275

@@ -76,40 +79,12 @@ export const searchCommand = new Command('search')
7679
return;
7780
}
7881

79-
// Pretty print results
80-
logger.log('');
81-
for (let i = 0; i < results.length; i++) {
82-
const result = results[i];
83-
const metadata = result.metadata;
84-
const score = (result.score * 100).toFixed(1);
85-
86-
// Extract file info (metadata uses 'path', not 'file')
87-
const filePath = (metadata.path || metadata.file) as string;
88-
const relativePath = filePath ? path.relative(resolvedRepoPath, filePath) : 'unknown';
89-
const startLine = metadata.startLine as number;
90-
const endLine = metadata.endLine as number;
91-
const name = metadata.name as string;
92-
const type = metadata.type as string;
93-
94-
logger.log(
95-
chalk.bold(`${i + 1}. ${chalk.cyan(name || type)} ${chalk.gray(`(${score}% match)`)}`)
96-
);
97-
logger.log(` ${chalk.gray('File:')} ${relativePath}:${startLine}-${endLine}`);
98-
99-
// Show signature if available
100-
if (metadata.signature) {
101-
logger.log(` ${chalk.gray('Signature:')} ${chalk.yellow(metadata.signature)}`);
102-
}
103-
104-
// Show docstring if available
105-
if (metadata.docstring) {
106-
const doc = String(metadata.docstring);
107-
const truncated = doc.length > 80 ? `${doc.substring(0, 77)}...` : doc;
108-
logger.log(` ${chalk.gray('Doc:')} ${truncated}`);
109-
}
110-
111-
logger.log('');
112-
}
82+
// Pretty print results (compact or verbose)
83+
output.log('');
84+
output.success(`Found ${results.length} result(s)`);
85+
output.log('');
86+
output.log(formatSearchResults(results, resolvedRepoPath, { verbose: options.verbose }));
87+
output.log('');
11388
} catch (error) {
11489
spinner.fail('Search failed');
11590
logger.error(error instanceof Error ? error.message : String(error));

0 commit comments

Comments
 (0)