From 854e3f6cd4394a84470b53c571d344ad12819079 Mon Sep 17 00:00:00 2001 From: Felipe Truman Date: Thu, 2 Jul 2026 13:00:42 -0300 Subject: [PATCH] feat: add standalone CLI (aicoach) for terminal and CI usage Runs the same parsing/analysis pipeline as the extension against every supported harness (VS Code/Copilot, Claude Code, Codex, OpenCode) and renders reports without an editor: an ANSI terminal dashboard, plus md/json/html report files. Includes commands for summary export, per-area JSON payloads, session listing/detail, anti-patterns, and context health, with date/workspace/harness filters. Local rules/metrics stay blocked by default (no interactive approval UI in a terminal); --allow-local-rules opts in explicitly. --- esbuild-cli.mjs | 21 +++ knip.json | 1 + package.json | 6 + src/cli.ts | 357 +++++++++++++++++++++++++++++++++++++ src/cli/args.ts | 138 ++++++++++++++ src/cli/cli.test.ts | 137 ++++++++++++++ src/cli/render-html.ts | 172 ++++++++++++++++++ src/cli/render-markdown.ts | 224 +++++++++++++++++++++++ src/cli/render-terminal.ts | 170 ++++++++++++++++++ src/cli/report-payload.ts | 188 +++++++++++++++++++ 10 files changed, 1414 insertions(+) create mode 100644 esbuild-cli.mjs create mode 100644 src/cli.ts create mode 100644 src/cli/args.ts create mode 100644 src/cli/cli.test.ts create mode 100644 src/cli/render-html.ts create mode 100644 src/cli/render-markdown.ts create mode 100644 src/cli/render-terminal.ts create mode 100644 src/cli/report-payload.ts diff --git a/esbuild-cli.mjs b/esbuild-cli.mjs new file mode 100644 index 0000000..15e5bf1 --- /dev/null +++ b/esbuild-cli.mjs @@ -0,0 +1,21 @@ +import esbuild from 'esbuild'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Standalone CLI bundle (dist/cli.js). Kept separate from esbuild.mjs so the +// extension build pipeline stays untouched. +await esbuild.build({ + entryPoints: [path.join(__dirname, 'src', 'cli.ts')], + bundle: true, + platform: 'node', + target: 'node20', + outfile: path.join(__dirname, 'dist', 'cli.js'), + format: 'cjs', + sourcemap: false, + minify: false, +}); + +console.log('CLI build complete: dist/cli.js'); diff --git a/knip.json b/knip.json index 98de6b9..81fcf3f 100644 --- a/knip.json +++ b/knip.json @@ -2,6 +2,7 @@ "$schema": "https://unpkg.com/knip@latest/schema.json", "entry": [ "src/extension.ts", + "src/cli.ts", "src/canvas/host.ts", "src/webview/app.ts", "src/core/warm-up-worker.ts", diff --git a/package.json b/package.json index 4376809..00598da 100644 --- a/package.json +++ b/package.json @@ -290,9 +290,15 @@ } ] }, + "bin": { + "aicoach": "./dist/cli.js", + "ai-engineer-coach": "./dist/cli.js" + }, "scripts": { "vscode:prepublish": "npm run build", "build": "node esbuild.mjs", + "build:cli": "node esbuild-cli.mjs", + "cli": "node dist/cli.js", "watch": "node esbuild.mjs --watch", "lint": "eslint src/", "bench": "vitest bench", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..48dd4f6 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,357 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Standalone CLI for AI Engineer Coach. + * + * Runs the same parsing/analysis pipeline as the VS Code extension against + * every supported harness (VS Code/Copilot, Claude Code, Codex, OpenCode) + * and renders reports to the terminal or to md/json/html files — no editor + * required. Build with `npm run build:cli`; entry point is `dist/cli.js`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { Analyzer } from './core/analyzer'; +import { findLogsDirs, parseAllLogsViaWorker } from './core/parser'; +import { loadAllRuleLayersAsync, loadAllMetricLayers } from './core/rule-loader'; +import { TrustGate } from './core/rule-trust'; + +import { + buildSummaryExportFromAnalyzer, + renderSummaryJson, + renderSummaryMarkdown, + getSummaryExportFilenames, +} from './core/summary-export'; + +import { + formatSummary, + formatActivity, + formatCredits, + formatCodeProduction, + formatFlow, + formatPatterns, + formatInsights, + formatWellbeing, + formatWorkflows, + formatHarnessComparison, + formatSessions, + formatContextHealth, +} from './mcp/formatters'; + +import { + CliOptions, + HELP_TEXT, + ReportFormat, + getBool, + getFilter, + getNumber, + getString, + normalizeReportFormats, + parseArgs, +} from './cli/args'; +import { ReportPayload, buildReportPayload, extractSection } from './cli/report-payload'; +import { renderAntiPatternsMarkdown, renderContextHealthMarkdown, renderProductMarkdown } from './cli/render-markdown'; +import { markdownToHtml } from './cli/render-html'; +import { renderTerminalDashboard, stripAnsi } from './cli/render-terminal'; + +function normalizeLogsDirs(opts: CliOptions): string[] { + const explicit = opts['logs-dir']; + + if (Array.isArray(explicit) && explicit.length > 0) { + return explicit.map(p => path.resolve(p)); + } + + return findLogsDirs(); +} + +function buildBlockingTrustGate(blockedLocalFiles: string[]): TrustGate { + return { + isAllowed() { + return false; + }, + onBlocked(entry) { + blockedLocalFiles.push(entry.filePath); + }, + }; +} + +async function createAnalyzer(opts: CliOptions): Promise { + const logsDirs = normalizeLogsDirs(opts); + + if (logsDirs.length === 0) { + throw new Error('No log directories found. Run `aicoach dirs` or pass --logs-dir .'); + } + + const projectRoot = path.resolve(getString(opts, 'project-root') ?? process.cwd()); + const showProgress = getBool(opts, 'verbose') && !getBool(opts, 'quiet'); + const blockedLocalFiles: string[] = []; + + // Local rules/metrics execute arbitrary logic, so they stay blocked unless + // the user explicitly opts in — the CLI has no interactive approval UI. + const trustGate = getBool(opts, 'allow-local-rules') ? undefined : buildBlockingTrustGate(blockedLocalFiles); + + await loadAllRuleLayersAsync(projectRoot, trustGate); + loadAllMetricLayers(projectRoot, trustGate); + + if (blockedLocalFiles.length > 0 && !getBool(opts, 'quiet')) { + console.error(`Local rules/metrics blocked for safety: ${blockedLocalFiles.length}`); + console.error('Pass --allow-local-rules if you trust these files.'); + } + + const parsed = await parseAllLogsViaWorker(logsDirs, progress => { + if (!showProgress) return; + + const pct = String(progress.pct).padStart(3, ' '); + const detail = progress.detail ? ` ${progress.detail}` : ''; + process.stderr.write(`\r${pct}%${detail}`); + }); + + if (showProgress) { + process.stderr.write('\n'); + } + + const analyzer = new Analyzer(parsed.sessions, parsed.editLocIndex, parsed.workspaces); + + if (!getBool(opts, 'no-warmup')) { + await analyzer.warmUp((_phase: number, detail: string, pct: number) => { + if (!showProgress) return; + process.stderr.write(`\r${pct}% ${detail}`); + }); + + if (showProgress) { + process.stderr.write('\n'); + } + } + + return analyzer; +} + +function writeOrPrint(content: string, outputPath?: string): void { + const normalized = content.endsWith('\n') ? content : `${content}\n`; + + if (!outputPath) { + process.stdout.write(normalized); + return; + } + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, normalized, 'utf8'); + console.log(outputPath); +} + +function writeReport(payload: ReportPayload, opts: CliOptions): void { + const formats = normalizeReportFormats(getString(opts, 'format')); + const outDir = getString(opts, 'out'); + + const terminal = outDir ? stripAnsi(renderTerminalDashboard(payload)) : renderTerminalDashboard(payload); + const markdown = renderProductMarkdown(payload); + + const contentByFormat: Record = { + terminal, + md: markdown, + json: JSON.stringify(payload, null, 2), + html: markdownToHtml(markdown), + }; + + if (!outDir && formats.length === 1) { + writeOrPrint(contentByFormat[formats[0]]); + return; + } + + const targetDir = path.resolve(outDir ?? './reports'); + const extByFormat: Record = { terminal: 'txt', md: 'md', json: 'json', html: 'html' }; + + for (const format of formats) { + writeOrPrint(contentByFormat[format], path.join(targetDir, `aicoach-report.${extByFormat[format]}`)); + } +} + +function commandDirs(opts: CliOptions): void { + console.log(JSON.stringify({ logsDirs: normalizeLogsDirs(opts) }, null, 2)); +} + +async function commandSummary(opts: CliOptions): Promise { + const analyzer = await createAnalyzer(opts); + const report = buildSummaryExportFromAnalyzer(analyzer, getFilter(opts)); + + const format = getString(opts, 'format') ?? 'md'; + const outDir = getString(opts, 'out'); + + if (outDir) { + const filenames = getSummaryExportFilenames(report.generatedAt); + const absOut = path.resolve(outDir); + + if (format === 'json' || format === 'both') { + writeOrPrint(renderSummaryJson(report), path.join(absOut, filenames.json)); + } + + if (format === 'md' || format === 'both') { + writeOrPrint(renderSummaryMarkdown(report), path.join(absOut, filenames.markdown)); + } + + return; + } + + writeOrPrint(format === 'json' ? renderSummaryJson(report) : renderSummaryMarkdown(report)); +} + +async function commandReport(opts: CliOptions): Promise { + const analyzer = await createAnalyzer(opts); + const filter = getFilter(opts); + const top = getNumber(opts, 'top') ?? 8; + + const summaryExport = buildSummaryExportFromAnalyzer(analyzer, filter); + const baseMarkdown = renderSummaryMarkdown(summaryExport); + const patternsData = formatPatterns(analyzer, filter); + const contextData = formatContextHealth(analyzer, filter); + + writeReport(buildReportPayload(baseMarkdown, patternsData, contextData, top), opts); +} + +async function commandJson(opts: CliOptions): Promise { + const kind = opts._[1] ?? 'summary'; + const analyzer = await createAnalyzer(opts); + const filter = getFilter(opts); + + const map: Record = { + summary: formatSummary(analyzer, filter), + activity: formatActivity(analyzer, filter), + credits: formatCredits(analyzer, filter), + production: formatCodeProduction(analyzer, filter), + flow: formatFlow(analyzer, filter), + patterns: formatPatterns(analyzer, filter), + insights: formatInsights(analyzer, filter), + wellbeing: formatWellbeing(analyzer, filter), + workflows: formatWorkflows(analyzer, filter), + compare: formatHarnessComparison(analyzer, filter), + context: formatContextHealth(analyzer, filter), + }; + + if (!(kind in map)) { + throw new Error(`Invalid json kind: ${kind}`); + } + + console.log(JSON.stringify(map[kind], null, 2)); +} + +async function commandSessions(opts: CliOptions): Promise { + const analyzer = await createAnalyzer(opts); + + const data = formatSessions( + analyzer, + { + page: getNumber(opts, 'page') ?? 1, + pageSize: getNumber(opts, 'page-size') ?? 20, + search: getString(opts, 'search'), + }, + getFilter(opts), + ); + + console.log(JSON.stringify(data, null, 2)); +} + +async function commandSession(opts: CliOptions): Promise { + const sessionId = getString(opts, 'session-id'); + + if (!sessionId) { + throw new Error('Usage: aicoach session --session-id '); + } + + const analyzer = await createAnalyzer(opts); + const data = formatSessions(analyzer, { sessionId }, getFilter(opts)); + + console.log(JSON.stringify(data, null, 2)); +} + +async function commandObserve(opts: CliOptions): Promise { + const subcommand = opts._[1] ?? 'summary'; + + if (subcommand !== 'summary') { + throw new Error(`Invalid observe subcommand: ${subcommand}`); + } + + opts.format = getString(opts, 'format') ?? 'md'; + return commandReport(opts); +} + +async function commandMeasure(opts: CliOptions): Promise { + const subcommand = opts._[1] ?? 'output'; + + if (subcommand !== 'output') { + throw new Error(`Invalid measure subcommand: ${subcommand}`); + } + + const analyzer = await createAnalyzer(opts); + const report = buildSummaryExportFromAnalyzer(analyzer, getFilter(opts)); + const markdown = renderSummaryMarkdown(report); + + const totals = extractSection(markdown, '## Totals'); + const topLanguages = extractSection(markdown, '## Top Languages'); + + console.log('# Code Output\n'); + console.log(totals || 'No totals found.'); + console.log(''); + console.log(topLanguages || 'No language output found.'); +} + +async function commandImprove(opts: CliOptions): Promise { + const subcommand = opts._[1] ?? 'anti-patterns'; + const analyzer = await createAnalyzer(opts); + const filter = getFilter(opts); + const top = getNumber(opts, 'top') ?? 8; + + if (subcommand === 'anti-patterns' || subcommand === 'patterns') { + console.log(renderAntiPatternsMarkdown(formatPatterns(analyzer, filter), top)); + return; + } + + if (subcommand === 'context-health' || subcommand === 'context') { + console.log(renderContextHealthMarkdown(formatContextHealth(analyzer, filter))); + return; + } + + throw new Error(`Invalid improve subcommand: ${subcommand}`); +} + +function routeDiagnosticsToStderr(quiet: boolean): void { + // Core helpers (infoCore/debugCore) log via console.info/console.debug, + // which write to stdout. The CLI reserves stdout for command output so it + // stays pipeable; diagnostics go to stderr (or are dropped with --quiet). + const sink = quiet ? () => undefined : (...args: unknown[]) => console.error(...args); + console.info = sink; + console.debug = sink; +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + const command = opts._[0] ?? 'help'; + + routeDiagnosticsToStderr(getBool(opts, 'quiet')); + + if (command === 'help' || getBool(opts, 'help')) { + console.log(HELP_TEXT); + return; + } + + if (command === 'dirs') return commandDirs(opts); + if (command === 'summary') return commandSummary(opts); + if (command === 'report') return commandReport(opts); + if (command === 'json') return commandJson(opts); + if (command === 'sessions') return commandSessions(opts); + if (command === 'session') return commandSession(opts); + if (command === 'observe') return commandObserve(opts); + if (command === 'measure') return commandMeasure(opts); + if (command === 'improve') return commandImprove(opts); + + throw new Error(`Invalid command: ${command}. Run \`aicoach help\`.`); +} + +main().catch((error: unknown) => { + console.error(''); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..0d983aa --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* CLI argument parsing and shared option helpers. */ + +import { DateFilter } from '../core/types'; + +export type CliOptions = Record & { + _: string[]; +}; + +export type ReportFormat = 'terminal' | 'md' | 'json' | 'html'; + +export function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { _: [] }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (!arg.startsWith('--')) { + opts._.push(arg); + continue; + } + + const eq = arg.indexOf('='); + const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2); + const inlineValue = eq >= 0 ? arg.slice(eq + 1) : undefined; + + let value: string | boolean = true; + + if (inlineValue !== undefined) { + value = inlineValue; + } else if (argv[i + 1] && !argv[i + 1].startsWith('--')) { + value = argv[i + 1]; + i++; + } + + // --logs-dir may repeat; collect every occurrence. + if (key === 'logs-dir') { + const current = opts[key]; + opts[key] = Array.isArray(current) ? [...current, String(value)] : [String(value)]; + } else { + opts[key] = value; + } + } + + return opts; +} + +export function getString(opts: CliOptions, key: string): string | undefined { + const value = opts[key]; + return typeof value === 'string' ? value : undefined; +} + +export function getBool(opts: CliOptions, key: string): boolean { + return opts[key] === true || opts[key] === 'true'; +} + +export function getNumber(opts: CliOptions, key: string): number | undefined { + const value = getString(opts, key); + if (!value) return undefined; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function getFilter(opts: CliOptions): DateFilter | undefined { + const filter: DateFilter = {}; + + const fromDate = getString(opts, 'from'); + const toDate = getString(opts, 'to'); + const workspaceId = getString(opts, 'workspace'); + const harness = getString(opts, 'harness'); + + if (fromDate) filter.fromDate = fromDate; + if (toDate) filter.toDate = toDate; + if (workspaceId) filter.workspaceId = workspaceId; + if (harness) filter.harness = harness; + + return Object.keys(filter).length > 0 ? filter : undefined; +} + +export function normalizeReportFormats(format: string | undefined): ReportFormat[] { + if (!format || format === 'terminal') return ['terminal']; + if (format === 'md') return ['md']; + if (format === 'json') return ['json']; + if (format === 'html') return ['html']; + if (format === 'both') return ['md', 'json']; + if (format === 'all') return ['terminal', 'md', 'json', 'html']; + + throw new Error(`Invalid format: ${format}`); +} + +export const HELP_TEXT = ` +AI Engineer Coach CLI + +Main usage: + aicoach report [--format terminal|md|json|html|both|all] [--out ./reports] + aicoach dirs + +Product areas: + aicoach observe summary + aicoach measure output + aicoach improve anti-patterns [--top 5] + aicoach improve context-health + +Technical commands: + aicoach summary [--format md|json|both] [--out ./reports] + aicoach json + aicoach sessions [--page 1] [--page-size 20] [--search term] + aicoach session --session-id + +Filters: + --from YYYY-MM-DD + --to YYYY-MM-DD + --workspace + --harness e.g. Claude, Codex, OpenCode, "VS Code" + +Options: + --logs-dir May repeat + --project-root Default: current directory + --out Write report files to a directory + --format terminal|md|json|html|both|all + --top Number of items in rankings + --allow-local-rules Admit local rules/metrics files + --no-warmup Skip the analyzer warm-up + --quiet Reduce stderr logging + --verbose Show parsing/warm-up progress + +Examples: + aicoach report + aicoach report --format all --out ./reports + aicoach improve anti-patterns --top 5 + aicoach measure output --from 2026-06-01 + aicoach sessions --harness OpenCode +`; diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts new file mode 100644 index 0000000..d87375d --- /dev/null +++ b/src/cli/cli.test.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Tests for the CLI helpers: argument parsing, payload extraction, and renderers. */ + +import { describe, it, expect } from 'vitest'; +import { getFilter, normalizeReportFormats, parseArgs } from './args'; +import { buildNextActions, extractBulletMap, extractBullets, extractSection } from './report-payload'; +import { escapeHtml, markdownToHtml } from './render-html'; +import { stripAnsi } from './render-terminal'; + +describe('parseArgs', () => { + it('collects positionals, boolean flags, and space/equals values', () => { + const opts = parseArgs(['report', '--quiet', '--format', 'md', '--top=5']); + + expect(opts._).toEqual(['report']); + expect(opts['quiet']).toBe(true); + expect(opts['format']).toBe('md'); + expect(opts['top']).toBe('5'); + }); + + it('accumulates repeated --logs-dir flags into an array', () => { + const opts = parseArgs(['dirs', '--logs-dir', '/a', '--logs-dir=/b']); + + expect(opts['logs-dir']).toEqual(['/a', '/b']); + }); +}); + +describe('getFilter', () => { + it('returns undefined when no filter flags are present', () => { + expect(getFilter(parseArgs(['report']))).toBeUndefined(); + }); + + it('maps from/to/workspace/harness flags onto a DateFilter', () => { + const filter = getFilter(parseArgs(['report', '--from', '2026-06-01', '--harness', 'OpenCode'])); + + expect(filter).toEqual({ fromDate: '2026-06-01', harness: 'OpenCode' }); + }); +}); + +describe('normalizeReportFormats', () => { + it('defaults to terminal and expands both/all aliases', () => { + expect(normalizeReportFormats(undefined)).toEqual(['terminal']); + expect(normalizeReportFormats('both')).toEqual(['md', 'json']); + expect(normalizeReportFormats('all')).toEqual(['terminal', 'md', 'json', 'html']); + }); + + it('rejects unknown formats', () => { + expect(() => normalizeReportFormats('pdf')).toThrow(/Invalid format/); + }); +}); + +const SAMPLE_MARKDOWN = [ + '## Totals', + '- Sessions: 10', + '- Requests: 42', + '', + '## Top Languages', + '- typescript: 100 AI LoC', + '- python: 50 AI LoC', + '', + '## Flow', + '- Overall flow score: 61', +].join('\n'); + +describe('markdown extraction helpers', () => { + it('extracts a section up to the next heading', () => { + const section = extractSection(SAMPLE_MARKDOWN, '## Totals'); + + expect(section).toContain('- Sessions: 10'); + expect(section).not.toContain('typescript'); + }); + + it('returns an empty string for a missing section', () => { + expect(extractSection(SAMPLE_MARKDOWN, '## Missing')).toBe(''); + }); + + it('parses bullet maps and bullet lists', () => { + expect(extractBulletMap(SAMPLE_MARKDOWN, '## Totals')).toEqual({ Sessions: '10', Requests: '42' }); + expect(extractBullets(SAMPLE_MARKDOWN, '## Top Languages')).toEqual([ + 'typescript: 100 AI LoC', + 'python: 50 AI LoC', + ]); + }); +}); + +describe('buildNextActions', () => { + it('prioritizes the first high-severity suggestion and deduplicates', () => { + const actions = buildNextActions({ + topAntiPatterns: [ + { name: 'A', severity: 'medium', group: 'g', occurrences: 5, suggestion: 'Medium tip' }, + { name: 'B', severity: 'high', group: 'g', occurrences: 3, suggestion: 'High tip' }, + ], + contextHealth: { + missingSignals: [{ label: 'Hooks', detail: 'No hooks configured' }], + workspaceSummary: [{ workspace: 'w', suggestions: ['High tip'] }], + }, + }); + + expect(actions[0]).toBe('High tip'); + expect(actions).toContain('Fix: Hooks. No hooks configured'); + expect(actions.filter(a => a === 'High tip')).toHaveLength(1); + }); +}); + +describe('markdownToHtml', () => { + it('escapes HTML in every rendered construct', () => { + const html = markdownToHtml('#