diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb9b04c..d336a10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, vnext] pull_request: jobs: @@ -23,10 +23,15 @@ jobs: - name: Typecheck run: npm run typecheck - name: Test - run: npm run test:coverage + run: npm run test - name: Build run: npm run build + - name: Verify CLI + run: | + chmod +x dist/index.js + node dist/index.js --version - name: Upload coverage + if: always() uses: actions/upload-artifact@v4 with: name: coverage diff --git a/.gitignore b/.gitignore index 73ca8dd..bdc551a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ npm-debug.log* # Primer outputs .primer/ +readiness-report.html diff --git a/README.md b/README.md index a761219..dd4a7f5 100644 --- a/README.md +++ b/README.md @@ -5,264 +5,190 @@ [![CI](https://github.com/pierceboggan/primer/actions/workflows/ci.yml/badge.svg)](https://github.com/pierceboggan/primer/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -Primer is a CLI tool that analyzes your codebase and generates `.github/copilot-instructions.md` files to help AI coding assistants understand your project better. It supports single repos, batch processing across organizations, and includes an evaluation framework to measure instruction effectiveness. - -![Primer](primer.png) +Primer is a CLI tool that helps teams prepare repositories for AI-assisted development. It generates custom instructions, assesses AI readiness with a maturity model, and supports batch processing across organizations — with an interactive TUI and beautiful visual reports. ## Features -- **Repository Analysis** - Detects languages, frameworks, and package managers -- **AI-Powered Generation** - Uses the Copilot SDK to analyze your codebase and generate context-aware instructions -- **Batch Processing** - Process multiple repos across organizations with a single command -- **Evaluation Framework** - Test and measure how well your instructions improve AI responses -- **Readiness Report** - Score AI readiness across key pillars with a fix-first checklist -- **GitHub Integration** - Clone repos, create branches, and open PRs automatically -- **Interactive TUI** - Beautiful terminal interface built with Ink -- **Config Generation** - Generate MCP and VS Code configurations +- **AI Readiness Reports** — Score repos across 9 pillars with a maturity model (Functional → Autonomous), including an AI tooling pillar that checks for MCP, custom agents, Copilot skills, and custom instructions +- **Visual Reports** — GitHub-themed HTML reports with light/dark toggle, expandable pillar details, and maturity model descriptions +- **Instruction Generation** — Generate `copilot-instructions.md` or `AGENTS.md` using the Copilot SDK, with per-app support for monorepos +- **Batch Processing** — Process multiple repos across GitHub or Azure DevOps organizations +- **Evaluation Framework** — Measure how instructions improve AI responses with a judge model +- **Interactive TUI** — Ink-based terminal UI with submenus, model picker, activity log, and animated banner +- **Config Generation** — Generate MCP and VS Code configurations +- **GitHub Integration** — Clone repos, create branches, and open PRs automatically ## Prerequisites -1. **Node.js 18+** -2. **GitHub Copilot CLI** - Installed via VS Code's Copilot Chat extension -3. **Copilot CLI Authentication** - Run `copilot` then `/login` to authenticate -4. **GitHub CLI (optional)** - For batch processing and PR creation: `brew install gh && gh auth login` -5. **Azure DevOps PAT (optional)** - For Azure DevOps batch/PR workflows: set `AZURE_DEVOPS_PAT` +1. **Node.js 20+** +2. **GitHub Copilot CLI** — Installed via VS Code's Copilot Chat extension +3. **Copilot CLI Authentication** — Run `copilot` then `/login` to authenticate +4. **GitHub CLI (optional)** — For batch processing and PR creation: `brew install gh && gh auth login` +5. **Azure DevOps PAT (optional)** — For Azure DevOps workflows: set `AZURE_DEVOPS_PAT` ## Installation ```bash -# Install from npm -npm install -g primer +# Clone and install +git clone https://github.com/pierceboggan/primer.git +cd primer +npm install +npm run build +npm link ``` -### Quick Install +Then use `primer` anywhere: ```bash primer --help ``` -### Local Development Install - -```bash -# Clone and install dependencies -git clone https://github.com/pierceboggan/primer.git -cd primer -npm install - -# Build and link the local CLI -npm run build -``` - ## Usage -### Quick Start (Init) - -The easiest way to get started is with the `init` command: +### Quick Start ```bash # Interactive setup for current directory primer init -# Accept defaults and generate instructions automatically +# Accept defaults and generate everything primer init --yes - -# Work with a GitHub repository -primer init --github - -# Work with an Azure DevOps repository -primer init --provider azure ``` -### Interactive Mode (TUI) +### Interactive TUI ```bash -# Run TUI in current directory primer tui - -# Run on a specific repo primer tui --repo /path/to/repo - -# Skip the animated intro -primer tui --no-animation ``` -**Keys:** -- `[A]` Analyze - Detect languages, frameworks, and package manager -- `[G]` Generate - Generate copilot-instructions.md using Copilot SDK -- `[S]` Save - Save generated instructions (in preview mode) -- `[D]` Discard - Discard generated instructions (in preview mode) +**Main menu:** +- `[G]` Generate → choose Copilot instructions or AGENTS.md (with per-app support for monorepos) +- `[E]` Eval → run evals or init eval config +- `[B]` Batch → pick GitHub or Azure DevOps +- `[M]` / `[J]` → pick eval/judge model from available models (arrow keys + Enter) - `[Q]` Quit ### Generate Instructions ```bash -# Generate instructions for current directory -primer instructions +# Generate copilot-instructions.md +primer generate instructions -# Generate for specific repo with custom output -primer instructions --repo /path/to/repo --output ./instructions.md +# Generate AGENTS.md +primer generate agents -# Use a specific model -primer instructions --model gpt-5 -``` +# Generate per-app in monorepos +primer generate instructions --per-app -### Batch Processing +# Generate MCP or VS Code configs +primer generate mcp +primer generate vscode --force +``` -Process multiple repositories across organizations: +Or use the standalone command: ```bash -# Launch batch TUI -primer batch - -# Launch batch TUI for Azure DevOps -primer batch --provider azure - -# Save results to file -primer batch --output results.json +primer instructions --repo /path/to/repo --model claude-sonnet-4.5 ``` -**Batch TUI Keys:** -- `[Space]` Toggle selection -- `[A]` Select all repos -- `[Enter]` Confirm selection -- `[Y/N]` Confirm/cancel processing -- `[Q]` Quit - ### Readiness Report -Assess how ready a repository is for AI agents and get a prioritized checklist of fixes: +Assess AI readiness across 9 pillars with a maturity model: ```bash -# Run readiness report in current directory +# Terminal output primer readiness -# Run readiness report on a specific repo -primer readiness /path/to/repo +# Visual HTML report (GitHub-themed, light/dark toggle) +primer readiness --visual -# Output JSON only +# JSON output primer readiness --json -# Write JSON report to a file -primer readiness --output readiness.json +# Save to file +primer readiness --output report.html ``` -### Examples +**Maturity levels:** +| Level | Name | Description | +|-------|------|-------------| +| 1 | Functional | Builds, tests, basic tooling in place | +| 2 | Documented | README, CONTRIBUTING, custom AI instructions exist | +| 3 | Standardized | CI/CD, security policies, CODEOWNERS, observability | +| 4 | Optimized | MCP servers, custom agents, AI skills configured | +| 5 | Autonomous | Full AI-native development with minimal oversight | -See [examples/README.md](examples/README.md) for quick usage snippets and a sample eval config. +**AI Tooling checks:** +- Custom instructions (`copilot-instructions.md`, `CLAUDE.md`, `AGENTS.md`, `.cursorrules`) +- MCP configuration (`.vscode/mcp.json`, settings) +- Custom AI agents (`.github/agents/`, `.copilot/agents/`) +- Copilot/Claude skills (`.copilot/skills/`, `.github/skills/`) -### Generate Configs +#### Batch Readiness -Generate configuration files for your repo: +Consolidated visual report across multiple repositories: ```bash -# Generate MCP config -primer generate mcp - -# Generate VS Code settings -primer generate vscode --force - -# Generate custom prompts -primer generate prompts - -# Generate agent configs -primer generate agents - -# Generate .aiignore file -primer generate aiignore +primer batch-readiness +primer batch-readiness --output team-readiness.html ``` -### Manage Templates +### Batch Processing -View available instruction templates: +Process multiple repos across organizations: ```bash -primer templates +# GitHub +primer batch + +# Azure DevOps +primer batch --provider azure ``` -### Configuration +### Evaluation Framework -View and manage Primer configuration: +Measure instruction effectiveness: ```bash -primer config -``` - -### Update +# Create eval config +primer eval --init -Check for and apply updates: +# Run evaluation (defaults to claude-sonnet-4.5) +primer eval primer.eval.json --repo /path/to/repo -```bash -primer update +# Custom models +primer eval --model claude-sonnet-4.5 --judge-model claude-sonnet-4.5 ``` ### Create Pull Requests -Automatically create a PR to add Primer configs to a repository: - ```bash -# Create PR for a GitHub repo primer pr owner/repo-name - -# Use custom branch name -primer pr owner/repo-name --branch primer/custom-branch - -# Create PR for an Azure DevOps repo (org/project/repo) primer pr my-org/my-project/my-repo --provider azure ``` -### Evaluation Framework - -Test how well your instructions improve AI responses: +## Development ```bash -# Create a starter eval config -primer eval --init - -# Run evaluation -primer eval primer.eval.json --repo /path/to/repo - -# Save results and use specific models -primer eval --output results.json --model gpt-5 --judge-model gpt-5 -``` - -When `--output` is provided (or `outputPath` is set in the eval config), Primer writes a JSON report that includes per-case metrics and trajectory events, and also generates a companion HTML trajectory viewer next to the JSON file. - -Example `primer.eval.json`: -```json -{ - "instructionFile": ".github/copilot-instructions.md", - "outputPath": "eval-results.json", - "cases": [ - { - "id": "project-overview", - "prompt": "Summarize what this project does and list the main entry points.", - "expectation": "Should mention the primary purpose and key files/directories." - } - ] -} -``` +# Type check +npm run typecheck -## How It Works +# Lint +npm run lint -1. **Analysis** - Scans the repository for: - - Language files (`.ts`, `.js`, `.py`, `.go`, etc.) - - Framework indicators (`package.json`, `tsconfig.json`, etc.) - - Package manager lock files +# Test (51 tests) +npm run test -2. **Generation** - Uses the Copilot SDK to: - - Start a Copilot CLI session - - Let the AI agent explore your codebase using tools (`glob`, `view`, `grep`) - - Generate concise, project-specific instructions +# Test with coverage +npm run test:coverage -3. **Batch Processing** - For multiple repos: - - Select organizations and repositories via TUI - - Clone, branch, generate, commit, push, and create PRs - - Track success/failure for each repository +# Build +npm run build -4. **Evaluation** - Measure instruction quality: - - Run prompts with and without instructions - - Use a judge model to score responses - - Generate comparison reports +# Run from source +npx tsx src/index.ts --help +``` ## Project Structure @@ -272,60 +198,37 @@ primer/ │ ├── index.ts # Entry point │ ├── cli.ts # Commander CLI setup │ ├── commands/ # CLI commands -│ │ ├── analyze.ts # Repository analysis -│ │ ├── batch.tsx # Batch processing -│ │ ├── config.ts # Config management -│ │ ├── eval.ts # Evaluation framework -│ │ ├── generate.ts # Config generation -│ │ ├── init.ts # Interactive setup -│ │ ├── instructions.tsx # Instructions generation -│ │ ├── pr.ts # PR creation -│ │ ├── templates.ts # Template management -│ │ ├── tui.tsx # TUI launcher -│ │ └── update.ts # Update command -│ ├── services/ # Core business logic -│ │ ├── analyzer.ts # Repository analysis -│ │ ├── evaluator.ts # Eval runner -│ │ ├── generator.ts # Config generation -│ │ ├── git.ts # Git operations -│ │ ├── github.ts # GitHub API -│ │ └── instructions.ts # Copilot SDK integration -│ ├── ui/ # Terminal UI +│ │ ├── batch.tsx # Batch processing (GitHub) +│ │ ├── batchReadiness.tsx # Batch readiness reports +│ │ ├── eval.ts # Evaluation framework +│ │ ├── generate.ts # Generate instructions/configs +│ │ ├── init.ts # Interactive setup +│ │ ├── instructions.tsx # Instructions generation +│ │ ├── pr.ts # PR creation +│ │ ├── readiness.ts # Readiness command +│ │ └── tui.tsx # TUI launcher +│ ├── services/ # Core logic +│ │ ├── analyzer.ts # Repository analysis +│ │ ├── evaluator.ts # Eval runner +│ │ ├── generator.ts # Config generation +│ │ ├── git.ts # Git operations +│ │ ├── github.ts # GitHub API +│ │ ├── instructions.ts # Copilot SDK integration +│ │ ├── readiness.ts # Readiness scoring engine +│ │ ├── visualReport.ts # HTML report generator +│ │ └── __tests__/ # Test suite +│ ├── ui/ # Terminal UI (Ink/React) │ │ ├── AnimatedBanner.tsx -│ │ ├── BatchTui.tsx # Batch processing UI -│ │ └── tui.tsx # Main TUI -│ └── utils/ # Helpers +│ │ ├── BatchTui.tsx +│ │ ├── BatchTuiAzure.tsx +│ │ ├── BatchReadinessTui.tsx +│ │ └── tui.tsx +│ └── utils/ │ ├── fs.ts │ └── logger.ts -├── package.json -├── tsconfig.json -├── primer.eval.json # Example eval config -└── PLAN.md # Project roadmap -``` - -## Development - -```bash -# Type check -npx tsc -p tsconfig.json --noEmit - -# Lint -npm run lint - -# Format -npm run format - -# Test -npm run test - -# Coverage -npm run test:coverage - -# Build and link the local CLI -npm run build - -# Run locally -primer +├── tsup.config.ts # Bundler config +├── vitest.config.ts # Test config +└── primer.eval.json # Example eval config ``` ## Troubleshooting @@ -336,16 +239,10 @@ Install the GitHub Copilot Chat extension in VS Code. The CLI is bundled with it ### "Copilot CLI not logged in" Run `copilot` in your terminal, then type `/login` to authenticate. -### "GitHub authentication required" (batch/PR commands) +### "GitHub authentication required" Install GitHub CLI and authenticate: `brew install gh && gh auth login` - Or set a token: `export GITHUB_TOKEN=` -### Generation hangs or times out -- Ensure you're authenticated with the Copilot CLI -- Check your network connection -- Try a smaller repository first - ## License MIT diff --git a/package.json b/package.json index bf64dc8..3c6fd55 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,18 @@ { - "name": "primer", + "name": "@anthropic/primer", "version": "1.0.0", - "description": "", + "description": "Prime repositories for AI-assisted development", "main": "dist/index.js", + "type": "module", + "bin": { + "primer": "dist/index.js" + }, + "files": [ + "dist" + ], "scripts": { - "build": "tsc && npm link", - "prepare": "tsc", + "build": "tsup", + "prepare": "test \"$CI\" = true || tsup", "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", @@ -44,9 +51,5 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^2.1.4" - }, - "type": "module", - "bin": { - "primer": "dist/index.js" } } diff --git a/src/cli.ts b/src/cli.ts index 0a35d90..6d8a169 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,14 +2,12 @@ import { Command } from "commander"; import { initCommand } from "./commands/init"; import { generateCommand } from "./commands/generate"; import { prCommand } from "./commands/pr"; -import { templatesCommand } from "./commands/templates"; -import { updateCommand } from "./commands/update"; -import { configCommand } from "./commands/config"; import { evalCommand } from "./commands/eval"; import { tuiCommand } from "./commands/tui"; import { instructionsCommand } from "./commands/instructions"; import { batchCommand } from "./commands/batch"; import { readinessCommand } from "./commands/readiness"; +import { batchReadinessCommand } from "./commands/batchReadiness"; export function runCli(argv: string[]): void { const program = new Command(); @@ -31,9 +29,10 @@ export function runCli(argv: string[]): void { program .command("generate") - .argument("", "prompts|agents|mcp|vscode|aiignore") + .argument("", "instructions|agents|mcp|vscode") .argument("[path]", "Path to a local repository") .option("--force", "Overwrite existing files") + .option("--per-app", "Generate per-app in monorepos") .action(generateCommand); program @@ -47,8 +46,8 @@ export function runCli(argv: string[]): void { .command("eval") .argument("[path]", "Path to eval config JSON") .option("--repo ", "Repository path", process.cwd()) - .option("--model ", "Model for responses", "gpt-5") - .option("--judge-model ", "Model for judging", "gpt-5") + .option("--model ", "Model for responses", "claude-sonnet-4.5") + .option("--judge-model ", "Model for judging", "claude-sonnet-4.5") .option("--list-models", "List Copilot CLI models and exit") .option("--output ", "Write results JSON to file") .option("--init", "Create a starter primer.eval.json file") @@ -65,14 +64,15 @@ export function runCli(argv: string[]): void { .command("instructions") .option("--repo ", "Repository path", process.cwd()) .option("--output ", "Output path for copilot instructions") - .option("--model ", "Model for instructions generation", "gpt-4.1") + .option("--model ", "Model for instructions generation", "claude-sonnet-4.5") .action(instructionsCommand); program .command("readiness") .argument("[path]", "Path to a local repository") .option("--json", "Output JSON") - .option("--output ", "Write JSON report to file") + .option("--output ", "Write report to file (.json or .html)") + .option("--visual", "Generate visual HTML report") .action(readinessCommand); program @@ -82,9 +82,11 @@ export function runCli(argv: string[]): void { .option("--provider ", "Repo provider (github|azure)", "github") .action(batchCommand); - program.command("templates").action(templatesCommand); - program.command("update").action(updateCommand); - program.command("config").action(configCommand); + program + .command("batch-readiness") + .description("Generate batch AI readiness report for multiple repos") + .option("--output ", "Write HTML report to file") + .action(batchReadinessCommand); program.parse(argv); } diff --git a/src/commands/batchReadiness.tsx b/src/commands/batchReadiness.tsx new file mode 100644 index 0000000..465ca61 --- /dev/null +++ b/src/commands/batchReadiness.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { render } from "ink"; +import { BatchReadinessTui } from "../ui/BatchReadinessTui"; +import { getGitHubToken } from "../services/github"; + +type BatchReadinessOptions = { + output?: string; +}; + +export async function batchReadinessCommand(options: BatchReadinessOptions): Promise { + const token = await getGitHubToken(); + if (!token) { + console.error("Error: GitHub authentication required."); + console.error(""); + console.error("Option 1 (recommended): Install and authenticate GitHub CLI"); + console.error(" brew install gh && gh auth login"); + console.error(""); + console.error("Option 2: Set a token environment variable"); + console.error(" export GITHUB_TOKEN="); + process.exitCode = 1; + return; + } + + const { waitUntilExit } = render( + + ); + + await waitUntilExit(); +} diff --git a/src/commands/generate.ts b/src/commands/generate.ts index a4c69c7..891f078 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,15 +1,19 @@ import path from "path"; +import fs from "fs/promises"; import { analyzeRepo } from "../services/analyzer"; import { generateConfigs } from "../services/generator"; +import { generateCopilotInstructions } from "../services/instructions"; +import { ensureDir } from "../utils/fs"; type GenerateOptions = { force?: boolean; + perApp?: boolean; }; export async function generateCommand(type: string, repoPathArg: string | undefined, options: GenerateOptions): Promise { - const allowed = new Set(["mcp", "vscode"]); + const allowed = new Set(["mcp", "vscode", "instructions", "agents"]); if (!allowed.has(type)) { - console.error("Invalid type. Use: mcp, vscode."); + console.error("Invalid type. Use: instructions, agents, mcp, vscode."); process.exitCode = 1; return; } @@ -17,6 +21,44 @@ export async function generateCommand(type: string, repoPathArg: string | undefi const repoPath = path.resolve(repoPathArg ?? process.cwd()); const analysis = await analyzeRepo(repoPath); + if (type === "instructions" || type === "agents") { + const apps = analysis.apps ?? []; + const targets: Array<{ repoPath: string; savePath: string; label: string }> = []; + + if (options.perApp && analysis.isMonorepo && apps.length > 1) { + for (const app of apps) { + const savePath = type === "instructions" + ? path.join(app.path, ".github", "copilot-instructions.md") + : path.join(app.path, "AGENTS.md"); + targets.push({ repoPath: app.path, savePath, label: app.name }); + } + } else { + const savePath = type === "instructions" + ? path.join(repoPath, ".github", "copilot-instructions.md") + : path.join(repoPath, "AGENTS.md"); + targets.push({ repoPath, savePath, label: path.basename(repoPath) }); + } + + for (const target of targets) { + console.log(`Generating ${type} for ${target.label}...`); + try { + const content = await generateCopilotInstructions({ + repoPath: target.repoPath, + }); + if (!content.trim()) { + console.error(` No content generated for ${target.label}.`); + continue; + } + await ensureDir(path.dirname(target.savePath)); + await fs.writeFile(target.savePath, content, "utf8"); + console.log(` ✓ ${path.relative(process.cwd(), target.savePath)}`); + } catch (error) { + console.error(` ✗ ${error instanceof Error ? error.message : String(error)}`); + } + } + return; + } + const selections = [type]; const result = await generateConfigs({ repoPath, diff --git a/src/commands/readiness.ts b/src/commands/readiness.ts index c2b65fa..642e471 100644 --- a/src/commands/readiness.ts +++ b/src/commands/readiness.ts @@ -6,19 +6,41 @@ import { ReadinessCriterionResult, runReadinessReport } from "../services/readiness"; +import { generateVisualReport } from "../services/visualReport"; type ReadinessOptions = { json?: boolean; output?: string; + visual?: boolean; }; export async function readinessCommand(repoPathArg: string | undefined, options: ReadinessOptions): Promise { const repoPath = path.resolve(repoPathArg ?? process.cwd()); const report = await runReadinessReport({ repoPath }); + const repoName = path.basename(repoPath); + + // Generate visual HTML report + if (options.visual || (options.output && options.output.endsWith('.html'))) { + const html = generateVisualReport({ + reports: [{ repo: repoName, report }], + title: `AI Readiness Report: ${repoName}`, + generatedAt: new Date().toISOString() + }); + + const outputPath = options.output + ? path.resolve(options.output) + : path.join(repoPath, 'readiness-report.html'); + + await fs.writeFile(outputPath, html, "utf8"); + console.log(chalk.green(`✓ Visual report generated: ${outputPath}`)); + return; + } - if (options.output) { + // Output JSON + if (options.output && options.output.endsWith('.json')) { const outputPath = path.resolve(options.output); await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8"); + console.log(chalk.green(`✓ JSON report saved: ${outputPath}`)); } if (options.json) { diff --git a/src/services/__tests__/fs.test.ts b/src/services/__tests__/fs.test.ts new file mode 100644 index 0000000..bd9e28e --- /dev/null +++ b/src/services/__tests__/fs.test.ts @@ -0,0 +1,73 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ensureDir, safeWriteFile } from "../../utils/fs"; + +describe("ensureDir", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-fs-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates a directory that does not exist", async () => { + const target = path.join(tmpDir, "a", "b", "c"); + await ensureDir(target); + + const stat = await fs.stat(target); + expect(stat.isDirectory()).toBe(true); + }); + + it("does not throw if directory already exists", async () => { + const target = path.join(tmpDir, "existing"); + await fs.mkdir(target); + await expect(ensureDir(target)).resolves.toBeUndefined(); + }); +}); + +describe("safeWriteFile", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-fs-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes a new file", async () => { + const filePath = path.join(tmpDir, "test.txt"); + const result = await safeWriteFile(filePath, "hello", false); + + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("hello"); + expect(result).toContain("Wrote"); + }); + + it("skips existing file without force", async () => { + const filePath = path.join(tmpDir, "test.txt"); + await fs.writeFile(filePath, "original"); + const result = await safeWriteFile(filePath, "new content", false); + + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("original"); + expect(result).toContain("Skipped"); + }); + + it("overwrites existing file with force", async () => { + const filePath = path.join(tmpDir, "test.txt"); + await fs.writeFile(filePath, "original"); + const result = await safeWriteFile(filePath, "new content", true); + + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("new content"); + expect(result).toContain("Wrote"); + }); +}); diff --git a/src/services/__tests__/readiness.test.ts b/src/services/__tests__/readiness.test.ts new file mode 100644 index 0000000..b6825cc --- /dev/null +++ b/src/services/__tests__/readiness.test.ts @@ -0,0 +1,360 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runReadinessReport, type ReadinessReport } from "../readiness"; + +describe("runReadinessReport", () => { + let repoPath: string; + + beforeEach(async () => { + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "primer-readiness-")); + }); + + afterEach(async () => { + await fs.rm(repoPath, { recursive: true, force: true }); + }); + + async function writeFile(relativePath: string, content: string): Promise { + const fullPath = path.join(repoPath, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, "utf8"); + } + + async function writePackageJson(pkg: Record): Promise { + await writeFile("package.json", JSON.stringify(pkg, null, 2)); + } + + it("returns a valid report structure", async () => { + await writePackageJson({ name: "test-repo", scripts: { build: "tsc", test: "vitest" } }); + const report = await runReadinessReport({ repoPath }); + + expect(report.repoPath).toBe(repoPath); + expect(report.generatedAt).toBeTruthy(); + expect(report.pillars).toBeInstanceOf(Array); + expect(report.levels).toBeInstanceOf(Array); + expect(report.criteria).toBeInstanceOf(Array); + expect(typeof report.achievedLevel).toBe("number"); + }); + + it("has all expected pillars", async () => { + await writePackageJson({ name: "test-repo" }); + const report = await runReadinessReport({ repoPath }); + + const pillarIds = report.pillars.map((p) => p.id); + expect(pillarIds).toContain("style-validation"); + expect(pillarIds).toContain("build-system"); + expect(pillarIds).toContain("testing"); + expect(pillarIds).toContain("documentation"); + expect(pillarIds).toContain("dev-environment"); + expect(pillarIds).toContain("code-quality"); + expect(pillarIds).toContain("observability"); + expect(pillarIds).toContain("security-governance"); + expect(pillarIds).toContain("ai-tooling"); + }); + + it("has 5 maturity levels", async () => { + await writePackageJson({ name: "test-repo" }); + const report = await runReadinessReport({ repoPath }); + + expect(report.levels).toHaveLength(5); + expect(report.levels.map((l) => l.level)).toEqual([1, 2, 3, 4, 5]); + }); + + describe("style-validation pillar", () => { + it("passes lint-config when eslint.config.js exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("eslint.config.js", "export default [];"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "lint-config"); + + expect(criterion?.status).toBe("pass"); + }); + + it("fails lint-config when no lint config exists", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "lint-config"); + + expect(criterion?.status).toBe("fail"); + }); + + it("passes typecheck-config when tsconfig.json exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("tsconfig.json", "{}"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "typecheck-config"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("documentation pillar", () => { + it("passes readme when README.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("README.md", "# Test"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "readme"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes contributing when CONTRIBUTING.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("CONTRIBUTING.md", "# Contributing"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "contributing"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("dev-environment pillar", () => { + it("passes lockfile when package-lock.json exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("package-lock.json", "{}"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "lockfile"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes env-example when .env.example exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".env.example", "API_KEY=your-key"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "env-example"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("security-governance pillar", () => { + it("passes codeowners when CODEOWNERS exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("CODEOWNERS", "* @owner"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "codeowners"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes codeowners when .github/CODEOWNERS exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/CODEOWNERS", "* @owner"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "codeowners"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes license when LICENSE exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("LICENSE", "MIT License"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "license"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes security-policy when SECURITY.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("SECURITY.md", "# Security"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "security-policy"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes dependabot when .github/dependabot.yml exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/dependabot.yml", "version: 2"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "dependabot"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("ai-tooling pillar", () => { + it("passes custom-instructions when copilot-instructions.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/copilot-instructions.md", "# Instructions"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-instructions when CLAUDE.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("CLAUDE.md", "# Claude instructions"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-instructions when AGENTS.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("AGENTS.md", "# Agents guidance"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-instructions when .cursorrules exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".cursorrules", "rules here"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("fails custom-instructions when none exist", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("fail"); + }); + + it("passes mcp-config when .vscode/mcp.json exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".vscode/mcp.json", "{}"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "mcp-config"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes mcp-config when .vscode/settings.json has mcp key", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile( + ".vscode/settings.json", + JSON.stringify({ "github.copilot.chat.mcp.enabled": true }) + ); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "mcp-config"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-agents when .github/agents directory exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/agents/.gitkeep", ""); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-agents"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes copilot-skills when .copilot/skills directory exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".copilot/skills/.gitkeep", ""); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "copilot-skills"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("build-system pillar", () => { + it("passes ci-config when .github/workflows exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/workflows/ci.yml", "name: CI"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "ci-config"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("achieved level", () => { + it("achieves level 1 with basic setup", async () => { + await writePackageJson({ + name: "test-repo", + scripts: { build: "tsc", test: "vitest" } + }); + await writeFile("eslint.config.js", "export default [];"); + await writeFile("README.md", "# Test"); + await writeFile("package-lock.json", "{}"); + await writeFile("LICENSE", "MIT"); + + const report = await runReadinessReport({ repoPath }); + + expect(report.achievedLevel).toBeGreaterThanOrEqual(1); + }); + + it("is 0 for an empty repo", async () => { + await writePackageJson({ name: "empty-repo" }); + + const report = await runReadinessReport({ repoPath }); + + // Level 0 means nothing achieved (most L1 checks fail) + expect(report.achievedLevel).toBeLessThanOrEqual(1); + }); + }); + + describe("pillar summaries", () => { + it("calculates passRate correctly", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("eslint.config.js", "export default [];"); + + const report = await runReadinessReport({ repoPath }); + const stylePillar = report.pillars.find((p) => p.id === "style-validation"); + + expect(stylePillar).toBeDefined(); + expect(stylePillar!.passed).toBeGreaterThanOrEqual(1); + expect(stylePillar!.total).toBeGreaterThanOrEqual(1); + expect(stylePillar!.passRate).toBe(stylePillar!.passed / stylePillar!.total); + }); + }); + + describe("extras", () => { + it("includes extras by default", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath }); + + expect(report.extras.length).toBeGreaterThan(0); + const extraIds = report.extras.map((e) => e.id); + expect(extraIds).toContain("pr-template"); + expect(extraIds).toContain("pre-commit"); + expect(extraIds).toContain("architecture-doc"); + }); + + it("excludes extras when disabled", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath, includeExtras: false }); + + expect(report.extras).toHaveLength(0); + }); + }); +}); diff --git a/src/services/__tests__/visualReport.test.ts b/src/services/__tests__/visualReport.test.ts new file mode 100644 index 0000000..27f199b --- /dev/null +++ b/src/services/__tests__/visualReport.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { generateVisualReport } from "../visualReport"; +import type { ReadinessReport } from "../readiness"; + +function makeReport(overrides: Partial = {}): ReadinessReport { + return { + repoPath: "/tmp/test-repo", + generatedAt: "2026-01-01T00:00:00.000Z", + isMonorepo: false, + apps: [], + pillars: [ + { id: "style-validation", name: "Style & Validation", passed: 2, total: 2, passRate: 1 }, + { id: "build-system", name: "Build System", passed: 1, total: 2, passRate: 0.5 }, + { id: "testing", name: "Testing", passed: 0, total: 1, passRate: 0 }, + { id: "documentation", name: "Documentation", passed: 1, total: 2, passRate: 0.5 }, + { id: "dev-environment", name: "Dev Environment", passed: 1, total: 2, passRate: 0.5 }, + { id: "code-quality", name: "Code Quality", passed: 1, total: 1, passRate: 1 }, + { id: "observability", name: "Observability", passed: 0, total: 1, passRate: 0 }, + { id: "security-governance", name: "Security & Governance", passed: 2, total: 4, passRate: 0.5 }, + { id: "ai-tooling", name: "AI Tooling", passed: 1, total: 4, passRate: 0.25 }, + ], + levels: [ + { level: 1, name: "Functional", passed: 5, total: 6, passRate: 0.83, achieved: true }, + { level: 2, name: "Documented", passed: 3, total: 6, passRate: 0.5, achieved: false }, + { level: 3, name: "Standardized", passed: 1, total: 4, passRate: 0.25, achieved: false }, + { level: 4, name: "Optimized", passed: 0, total: 0, passRate: 0, achieved: false }, + { level: 5, name: "Autonomous", passed: 0, total: 0, passRate: 0, achieved: false }, + ], + achievedLevel: 1, + criteria: [ + { id: "lint-config", title: "Linting configured", pillar: "style-validation", level: 1, scope: "repo", impact: "high", effort: "low", status: "pass" }, + { id: "readme", title: "README present", pillar: "documentation", level: 1, scope: "repo", impact: "high", effort: "low", status: "pass" }, + { id: "custom-instructions", title: "Custom AI instructions", pillar: "ai-tooling", level: 1, scope: "repo", impact: "high", effort: "low", status: "pass" }, + { id: "mcp-config", title: "MCP config present", pillar: "ai-tooling", level: 2, scope: "repo", impact: "high", effort: "low", status: "fail", reason: "Missing MCP config." }, + ], + extras: [], + ...overrides, + }; +} + +describe("generateVisualReport", () => { + it("returns valid HTML", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("includes the report title", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + title: "My Custom Report", + }); + + expect(html).toContain("My Custom Report"); + }); + + it("includes repo name", () => { + const html = generateVisualReport({ + reports: [{ repo: "my-repo", report: makeReport() }], + }); + + expect(html).toContain("my-repo"); + }); + + it("includes pillar names", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain("Style & Validation"); + expect(html).toContain("Build System"); + expect(html).toContain("AI Tooling"); + }); + + it("includes maturity level badge", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport({ achievedLevel: 2 }) }], + }); + + expect(html).toContain("Maturity 2"); + expect(html).toContain("Documented"); + }); + + it("includes AI Tooling Readiness hero section", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain("AI Tooling Readiness"); + }); + + it("includes maturity model descriptions", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain("Functional"); + expect(html).toContain("Documented"); + expect(html).toContain("Standardized"); + expect(html).toContain("Optimized"); + expect(html).toContain("Autonomous"); + }); + + it("includes theme toggle", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain("toggleTheme"); + expect(html).toContain("data-theme"); + }); + + it("includes light theme CSS variables", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain('[data-theme="light"]'); + expect(html).toContain('[data-theme="dark"]'); + }); + + it("includes GitHub logo SVG", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain("header-logo"); + expect(html).toContain(" { + const html = generateVisualReport({ + reports: [ + { repo: "good-repo", report: makeReport() }, + { repo: "bad-repo", report: makeReport(), error: "Clone failed" }, + ], + }); + + expect(html).toContain("bad-repo"); + expect(html).toContain("Clone failed"); + }); + + it("shows summary cards with correct counts", () => { + const html = generateVisualReport({ + reports: [ + { repo: "repo-1", report: makeReport() }, + { repo: "repo-2", report: makeReport() }, + ], + }); + + // Total repos should be 2 + expect(html).toContain(">2<"); + }); + + it("includes top fixes for failing criteria", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + }); + + expect(html).toContain("Top Fixes"); + expect(html).toContain("MCP config present"); + }); + + it("shows all criteria passing when all pass", () => { + const report = makeReport({ + criteria: [ + { id: "lint-config", title: "Linting configured", pillar: "style-validation", level: 1, scope: "repo", impact: "high", effort: "low", status: "pass" }, + ], + }); + + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report }], + }); + + expect(html).toContain("All criteria passing"); + }); + + it("escapes HTML in repo names", () => { + const html = generateVisualReport({ + reports: [{ repo: '', report: makeReport() }], + }); + + expect(html).not.toContain(''); + expect(html).toContain("<script>"); + }); +}); diff --git a/src/services/evaluator.ts b/src/services/evaluator.ts index 6e4b797..1a137cd 100644 --- a/src/services/evaluator.ts +++ b/src/services/evaluator.ts @@ -637,281 +637,314 @@ function buildViewerPath(outputPath: string): string { function buildTrajectoryViewerHtml(data: Record): string { const serialized = JSON.stringify(data).replace(/ - - - - - Primer Eval Trajectory - - - -

Primer Eval Trajectory

-
-
-
-
-
+ + + + +Primer Eval Results + + + +
+
+ +
+

Eval Results

+
- - + '
' + + '
' + + '
'; + }); + document.getElementById('caseDetails').innerHTML = html; +} +renderCaseDetails(); + + `; } diff --git a/src/services/github.ts b/src/services/github.ts index fbabaea..91ac04f 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -113,7 +113,7 @@ export async function listUserOrgs(token: string): Promise { return orgs.map((org) => ({ login: org.login, - name: org.name ?? null + name: org.description ?? null })); } diff --git a/src/services/instructions.ts b/src/services/instructions.ts index 1d7cf62..8135932 100644 --- a/src/services/instructions.ts +++ b/src/services/instructions.ts @@ -26,7 +26,7 @@ export async function generateCopilotInstructions(options: GenerateInstructionsO try { progress("Creating session..."); // Try requested model, fall back to gpt-4.1 if gpt-5 fails - const preferredModel = options.model ?? "gpt-4.1"; + const preferredModel = options.model ?? "claude-sonnet-4.5"; const session = await client.createSession({ model: preferredModel, streaming: true, diff --git a/src/services/readiness.ts b/src/services/readiness.ts index ea55e77..5964b25 100644 --- a/src/services/readiness.ts +++ b/src/services/readiness.ts @@ -10,7 +10,8 @@ export type ReadinessPillar = | "dev-environment" | "code-quality" | "observability" - | "security-governance"; + | "security-governance" + | "ai-tooling"; export type ReadinessScope = "repo" | "app"; @@ -403,6 +404,99 @@ function buildCriteria(): ReadinessCriterion[] { reason: "No observability dependencies detected (OpenTelemetry/logging)." }; } + }, + { + id: "custom-instructions", + title: "Custom AI instructions or agent guidance", + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const rootFound = await hasCustomInstructions(context.repoPath); + if (rootFound.length === 0) { + return { + status: "fail", + reason: "Missing custom AI instructions (e.g. copilot-instructions.md, CLAUDE.md, AGENTS.md, .cursorrules).", + evidence: ["copilot-instructions.md", "CLAUDE.md", "AGENTS.md", ".cursorrules", ".github/copilot-instructions.md"] + }; + } + + // For monorepos, also check that each app has its own instructions + if (context.analysis.isMonorepo && context.apps.length > 1) { + const appsMissing: string[] = []; + for (const app of context.apps) { + const appFound = await hasCustomInstructions(app.path); + if (appFound.length === 0) { + appsMissing.push(app.name); + } + } + if (appsMissing.length > 0) { + return { + status: "pass", + reason: `Root instructions found, but ${appsMissing.length}/${context.apps.length} apps missing their own: ${appsMissing.join(", ")}`, + evidence: [...rootFound, ...appsMissing.map(name => `${name}: missing app-level instructions`)] + }; + } + } + + return { + status: "pass", + evidence: rootFound + }; + } + }, + { + id: "mcp-config", + title: "MCP configuration present", + pillar: "ai-tooling", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasMcpConfig(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "Missing MCP (Model Context Protocol) configuration (e.g. .vscode/mcp.json).", + evidence: found.length > 0 ? found : [".vscode/mcp.json", ".vscode/settings.json (mcp section)", "mcp.json"] + }; + } + }, + { + id: "custom-agents", + title: "Custom AI agents configured", + pillar: "ai-tooling", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await hasCustomAgents(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "No custom AI agents configured (e.g. .github/agents/, .copilot/agents/).", + evidence: found.length > 0 ? found : [".github/agents/", ".copilot/agents/", ".github/copilot/agents/"] + }; + } + }, + { + id: "copilot-skills", + title: "Copilot/Claude skills present", + pillar: "ai-tooling", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await hasCopilotSkills(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "No Copilot or Claude skills found (e.g. .copilot/skills/, .github/skills/).", + evidence: found.length > 0 ? found : [".copilot/skills/", ".github/skills/", ".claude/skills/"] + }; + } } ]; } @@ -450,7 +544,8 @@ function summarizePillars(criteria: ReadinessCriterionResult[]): ReadinessPillar "dev-environment": "Dev Environment", "code-quality": "Code Quality", observability: "Observability", - "security-governance": "Security & Governance" + "security-governance": "Security & Governance", + "ai-tooling": "AI Tooling" }; return (Object.keys(pillarNames) as ReadinessPillar[]).map((pillar) => { @@ -622,6 +717,91 @@ async function hasArchitectureDoc(repoPath: string): Promise { return fileExists(path.join(repoPath, "docs", "architecture.md")); } +async function hasCustomInstructions(repoPath: string): Promise { + const found: string[] = []; + const candidates = [ + ".github/copilot-instructions.md", + "CLAUDE.md", + ".claude/CLAUDE.md", + "AGENTS.md", + ".github/AGENTS.md", + ".cursorrules", + ".cursorignore", + ".windsurfrules", + ".github/instructions.md", + "copilot-instructions.md" + ]; + for (const candidate of candidates) { + if (await fileExists(path.join(repoPath, candidate))) { + found.push(candidate); + } + } + return found; +} + +async function hasMcpConfig(repoPath: string): Promise { + const found: string[] = []; + // Check .vscode/mcp.json + if (await fileExists(path.join(repoPath, ".vscode", "mcp.json"))) { + found.push(".vscode/mcp.json"); + } + // Check root mcp.json + if (await fileExists(path.join(repoPath, "mcp.json"))) { + found.push("mcp.json"); + } + // Check .vscode/settings.json for MCP section + const settings = await readJson(path.join(repoPath, ".vscode", "settings.json")); + if (settings && (settings["mcp"] || settings["github.copilot.chat.mcp.enabled"])) { + found.push(".vscode/settings.json (mcp section)"); + } + // Check .claude/mcp.json + if (await fileExists(path.join(repoPath, ".claude", "mcp.json"))) { + found.push(".claude/mcp.json"); + } + return found; +} + +async function hasCustomAgents(repoPath: string): Promise { + const found: string[] = []; + const agentDirs = [ + ".github/agents", + ".copilot/agents", + ".github/copilot/agents" + ]; + for (const dir of agentDirs) { + if (await fileExists(path.join(repoPath, dir))) { + found.push(dir); + } + } + // Check for agent config files + const agentFiles = [ + ".github/copilot-agents.yml", + ".github/copilot-agents.yaml" + ]; + for (const agentFile of agentFiles) { + if (await fileExists(path.join(repoPath, agentFile))) { + found.push(agentFile); + } + } + return found; +} + +async function hasCopilotSkills(repoPath: string): Promise { + const found: string[] = []; + const skillDirs = [ + ".copilot/skills", + ".github/skills", + ".claude/skills", + ".github/copilot/skills" + ]; + for (const dir of skillDirs) { + if (await fileExists(path.join(repoPath, dir))) { + found.push(dir); + } + } + return found; +} + async function readAllDependencies(context: ReadinessContext): Promise { const dependencies: string[] = []; const apps = context.apps.length ? context.apps : []; diff --git a/src/services/visualReport.ts b/src/services/visualReport.ts new file mode 100644 index 0000000..115590c --- /dev/null +++ b/src/services/visualReport.ts @@ -0,0 +1,805 @@ +import path from "path"; +import { ReadinessReport, ReadinessCriterionResult } from "./readiness"; + +type VisualReportOptions = { + reports: Array<{ repo: string; report: ReadinessReport; error?: string }>; + title?: string; + generatedAt?: string; +}; + +export function generateVisualReport(options: VisualReportOptions): string { + const { reports, title = "AI Readiness Report", generatedAt = new Date().toISOString() } = options; + + const successfulReports = reports.filter(r => !r.error); + const failedReports = reports.filter(r => r.error); + + const totalRepos = reports.length; + const successfulRepos = successfulReports.length; + const avgLevel = successfulReports.length > 0 + ? successfulReports.reduce((sum, r) => sum + r.report.achievedLevel, 0) / successfulReports.length + : 0; + + const pillarStats = calculatePillarStats(successfulReports); + const aiToolingData = calculateAiToolingData(successfulReports); + + return ` + + + + + ${escapeHtml(title)} + + + +
+
+ +
+

${escapeHtml(title)}

+

Generated ${new Date(generatedAt).toLocaleString()}

+
+ +
+ +
+
+
Repositories
+
${totalRepos}
+
${successfulRepos} analyzed successfully
+
+
+
Avg Maturity
+
${avgLevel.toFixed(1)}
+
${getLevelName(Math.round(avgLevel))}
+
+
+
Success Rate
+
${totalRepos > 0 ? Math.round((successfulRepos / totalRepos) * 100) : 0}%
+
${failedReports.length > 0 ? failedReports.length + ' failed' : 'All succeeded'}
+
+
+ + ${successfulReports.length > 0 ? ` + ${buildAiToolingHeroHtml(aiToolingData, successfulReports)} + +
+

Pillar Performance

+
+ ${pillarStats.map(pillar => ` +
+
${escapeHtml(pillar.name)}
+
+
+
+
+ ${pillar.passed}/${pillar.total} (${Math.round(pillar.passRate * 100)}%) +
+
+ `).join('')} +
+
+ +
+

Maturity Model

+
+ ${[1, 2, 3, 4, 5].map(level => { + const count = successfulReports.filter(r => r.report.achievedLevel === level).length; + return ` +
+
+ ${level} + ${getLevelName(level)} + ${count} repo${count !== 1 ? 's' : ''} +
+
${getLevelDescription(level)}
+
+ `; + }).join('')} +
+ +

Distribution

+
+ ${[1, 2, 3, 4, 5].map(level => { + const count = successfulReports.filter(r => r.report.achievedLevel === level).length; + const percent = successfulReports.length > 0 ? (count / successfulReports.length) * 100 : 0; + const barHeight = count > 0 ? Math.max(40, percent * 2) : 0; + return ` +
+
${count}
+
+
${level}
${getLevelName(level)}
+
+ `; + }).join('')} +
+
+ ` : ''} + +
+

Repository Details

+
+ ${reports.map(({ repo, report, error }) => { + if (error) { + return ` +
+
+
${escapeHtml(repo)}
+ Error +
+
${escapeHtml(error)}
+
+ `; + } + + return ` +
+
+
${escapeHtml(repo)}
+
+ Maturity ${report.achievedLevel}: ${getLevelName(report.achievedLevel)} +
+
+ ${report.isMonorepo ? `
Monorepo · ${report.apps.length} apps
` : ''} +
+ ${report.pillars.map(pillar => { + const pillarCriteria = report.criteria.filter(c => c.pillar === pillar.id); + return ` +
+
+ + ${escapeHtml(pillar.name)} + ${pillar.passed}/${pillar.total} (${Math.round(pillar.passRate * 100)}%) + +
+ ${pillarCriteria.map(c => ` +
+ ${escapeHtml(c.title)} + ${c.status === 'pass' ? 'Pass' : c.status === 'fail' ? 'Fail' : 'Skip'} +
+ `).join('')} + ${pillarCriteria.length === 0 ? '
No criteria
' : ''} +
+
+
+ `; + }).join('')} +
+ ${getTopFixesHtml(report)} +
+ `; + }).join('')} +
+
+ + ${failedReports.length > 0 ? ` +
+

Failed Repositories

+
+ ${failedReports.map(({ repo, error }) => ` +
+
${escapeHtml(repo)}
+
${escapeHtml(error || 'Unknown error')}
+
+ `).join('')} +
+
+ ` : ''} + + +
+ + +`; +} + +// ── Helper Functions ────────────────────────────────────────────────── + +function calculatePillarStats(reports: Array<{ repo: string; report: ReadinessReport }>): Array<{ + id: string; + name: string; + passed: number; + total: number; + passRate: number; +}> { + const pillarMap = new Map(); + + for (const { report } of reports) { + for (const pillar of report.pillars) { + const existing = pillarMap.get(pillar.id); + if (existing) { + existing.passed += pillar.passed; + existing.total += pillar.total; + } else { + pillarMap.set(pillar.id, { + name: pillar.name, + passed: pillar.passed, + total: pillar.total + }); + } + } + } + + return Array.from(pillarMap.entries()).map(([id, stats]) => ({ + id, + name: stats.name, + passed: stats.passed, + total: stats.total, + passRate: stats.total > 0 ? stats.passed / stats.total : 0 + })); +} + +function getTopFixesHtml(report: ReadinessReport): string { + const failedCriteria = report.criteria + .filter(c => c.status === "fail") + .sort((a, b) => { + const impactWeight = { high: 3, medium: 2, low: 1 }; + const effortWeight = { low: 1, medium: 2, high: 3 }; + const impactDelta = impactWeight[b.impact] - impactWeight[a.impact]; + if (impactDelta !== 0) return impactDelta; + return effortWeight[a.effort] - effortWeight[b.effort]; + }) + .slice(0, 3); + + if (failedCriteria.length === 0) { + return '
All criteria passing
'; + } + + return ` +
+
Top Fixes Needed
+
    + ${failedCriteria.map(c => ` +
  • + + ${escapeHtml(c.title)} + ${c.impact} impact, ${c.effort} effort +
  • + `).join('')} +
+
+ `; +} + +function getLevelName(level: number): string { + const names: Record = { + 1: "Functional", + 2: "Documented", + 3: "Standardized", + 4: "Optimized", + 5: "Autonomous" + }; + return names[level] || "Unknown"; +} + +function getLevelDescription(level: number): string { + const descriptions: Record = { + 1: "Repo builds, tests run, and basic tooling (linter, lockfile) is in place. AI agents can clone and get started.", + 2: "README, CONTRIBUTING guide, and custom AI instructions exist. Agents understand project context and conventions.", + 3: "CI/CD, security policies, CODEOWNERS, and observability are configured. Agents operate within well-defined guardrails.", + 4: "MCP servers, custom agents, and AI skills are set up. Agents have deep integration with project-specific tools and workflows.", + 5: "Full AI-native development: agents can independently plan, implement, test, and ship changes with minimal human oversight." + }; + return descriptions[level] || ""; +} + +function getProgressClass(passRate: number): string { + if (passRate >= 0.8) return "high"; + if (passRate >= 0.5) return "medium"; + return "low"; +} + +// ── AI Tooling Hero ─────────────────────────────────────────────────── + +type AiToolingCriterionSummary = { + id: string; + title: string; + passCount: number; + totalRepos: number; + status: "pass" | "fail"; + evidence: string[]; + reason: string; +}; + +type AiToolingData = { + criteria: AiToolingCriterionSummary[]; + passed: number; + total: number; + passRate: number; +}; + +function calculateAiToolingData(reports: Array<{ repo: string; report: ReadinessReport }>): AiToolingData { + const criterionMap = new Map(); + + for (const { report } of reports) { + const aiCriteria = report.criteria.filter(c => c.pillar === "ai-tooling"); + for (const c of aiCriteria) { + const existing = criterionMap.get(c.id); + if (existing) { + existing.totalRepos += 1; + if (c.status === "pass") existing.passCount += 1; + if (c.evidence) existing.evidence.push(...c.evidence); + } else { + criterionMap.set(c.id, { + id: c.id, + title: c.title, + passCount: c.status === "pass" ? 1 : 0, + totalRepos: 1, + status: c.status === "pass" ? "pass" : "fail", + evidence: c.evidence ? [...c.evidence] : [], + reason: c.reason || "" + }); + } + } + } + + const criteria = Array.from(criterionMap.values()).map(c => ({ + ...c, + status: (c.passCount / c.totalRepos >= 0.5 ? "pass" : "fail") as "pass" | "fail", + evidence: [...new Set(c.evidence)] + })); + + const passed = criteria.filter(c => c.status === "pass").length; + return { + criteria, + passed, + total: criteria.length, + passRate: criteria.length > 0 ? passed / criteria.length : 0 + }; +} + +function getAiScoreClass(passRate: number): string { + if (passRate >= 0.6) return "score-high"; + if (passRate >= 0.3) return "score-medium"; + return "score-low"; +} + +function getAiScoreLabel(passRate: number): string { + if (passRate >= 0.8) return "Excellent"; + if (passRate >= 0.6) return "Good"; + if (passRate >= 0.4) return "Fair"; + if (passRate >= 0.2) return "Getting Started"; + return "Not Started"; +} + +function getAiCriterionIcon(id: string): string { + const icons: Record = { + "custom-instructions": "📝", + "mcp-config": "🔌", + "custom-agents": "🤖", + "copilot-skills": "⚡" + }; + return icons[id] || "🔧"; +} + +function buildAiToolingHeroHtml(data: AiToolingData, reports: Array<{ repo: string; report: ReadinessReport }>): string { + if (data.criteria.length === 0) return ""; + + const pct = Math.round(data.passRate * 100); + const scoreClass = getAiScoreClass(data.passRate); + const scoreLabel = getAiScoreLabel(data.passRate); + + const multiRepo = reports.length > 1; + const perRepoHtml = multiRepo ? ` +
+
Per Repository
+
+ ${reports.map(({ repo, report }) => { + const aiPillar = report.pillars.find(p => p.id === "ai-tooling"); + const repoPct = aiPillar ? Math.round(aiPillar.passRate * 100) : 0; + const repoPass = aiPillar?.passed ?? 0; + const repoTotal = aiPillar?.total ?? 0; + return `
+ ${escapeHtml(repo)} + ${repoPass}/${repoTotal} (${repoPct}%) +
`; + }).join('')} +
+
+ ` : ''; + + return ` +
+

AI Tooling Readiness

+

How well prepared ${multiRepo ? 'your repositories are' : 'this repository is'} for AI-assisted development

+ +
+
${pct}%
+
+
${scoreLabel}
+
${data.passed} of ${data.total} AI tooling checks passing${multiRepo ? ` across ${reports.length} repositories` : ''}
+
+
+ +
+ ${data.criteria.map(c => ` +
+
+ ${c.status === 'pass' ? '✓' : '✗'} +
+
+
${getAiCriterionIcon(c.id)} ${escapeHtml(c.title)}
+
${c.status === 'pass' + ? (multiRepo ? `${c.passCount}/${c.totalRepos} repos` : 'Detected') + : escapeHtml(c.reason)}
+
+
+ `).join('')} +
+ ${perRepoHtml} +
+ `; +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return text.replace(/[&<>"']/g, m => map[m]); +} diff --git a/src/ui/BatchReadinessTui.tsx b/src/ui/BatchReadinessTui.tsx new file mode 100644 index 0000000..83c4039 --- /dev/null +++ b/src/ui/BatchReadinessTui.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text, useApp, useInput } from "ink"; +import path from "path"; +import fs from "fs/promises"; +import os from "os"; +import { + GitHubOrg, + GitHubRepo, + listUserOrgs, + listOrgRepos, + listAccessibleRepos +} from "../services/github"; +import { cloneRepo } from "../services/git"; +import { runReadinessReport, ReadinessReport } from "../services/readiness"; +import { generateVisualReport } from "../services/visualReport"; +import { ensureDir } from "../utils/fs"; +import { StaticBanner } from "./AnimatedBanner"; + +type Props = { + token: string; + outputPath?: string; +}; + +type Status = + | "loading-orgs" + | "select-orgs" + | "loading-repos" + | "select-repos" + | "confirm" + | "processing" + | "complete" + | "error"; + +type ProcessResult = { + repo: string; + report?: ReadinessReport; + error?: string; +}; + +export function BatchReadinessTui({ token, outputPath }: Props): React.JSX.Element { + const app = useApp(); + const [status, setStatus] = useState("loading-orgs"); + const [message, setMessage] = useState("Fetching organizations..."); + const [errorMessage, setErrorMessage] = useState(""); + + // Data + const [orgs, setOrgs] = useState([]); + const [repos, setRepos] = useState([]); + const [selectedOrgIndices, setSelectedOrgIndices] = useState>(new Set()); + const [selectedRepoIndices, setSelectedRepoIndices] = useState>(new Set()); + const [cursorIndex, setCursorIndex] = useState(0); + + // Processing + const [results, setResults] = useState([]); + const [currentRepoIndex, setCurrentRepoIndex] = useState(0); + const [processingMessage, setProcessingMessage] = useState(""); + + // Load orgs on mount + useEffect(() => { + loadOrgs(); + }, []); + + async function loadOrgs() { + try { + const userOrgs = await listUserOrgs(token); + const allOrgs: GitHubOrg[] = [ + { login: "__personal__", name: "Personal Repositories" }, + ...userOrgs + ]; + setOrgs(allOrgs); + setStatus("select-orgs"); + setMessage("Select organizations (space to toggle, enter to confirm)"); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch organizations"); + } + } + + async function loadRepos() { + setStatus("loading-repos"); + setMessage("Fetching repositories..."); + try { + const selectedOrgs = Array.from(selectedOrgIndices).map(i => orgs[i]); + let allRepos: GitHubRepo[] = []; + + for (let idx = 0; idx < selectedOrgs.length; idx++) { + const org = selectedOrgs[idx]; + setMessage(`Fetching repos from ${org.name ?? org.login} (${idx + 1}/${selectedOrgs.length})...`); + + if (org.login === "__personal__") { + const personalRepos = await listAccessibleRepos(token); + const userRepos = personalRepos + .filter(r => !orgs.some(o => o.login !== "__personal__" && o.login === r.owner)) + .slice(0, 100); + allRepos = [...allRepos, ...userRepos]; + } else { + const orgRepos = await listOrgRepos(token, org.login, 100); + allRepos = [...allRepos, ...orgRepos]; + } + } + + setRepos(allRepos); + setStatus("select-repos"); + setMessage(`Select repositories (${allRepos.length} available)`); + setCursorIndex(0); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch repositories"); + } + } + + async function processRepos() { + setStatus("processing"); + const selectedRepos = Array.from(selectedRepoIndices).map(i => repos[i]); + const results: ProcessResult[] = []; + const tmpDir = path.join(os.tmpdir(), `primer-batch-readiness-${Date.now()}`); + + try { + await ensureDir(tmpDir); + + for (let i = 0; i < selectedRepos.length; i++) { + const repo = selectedRepos[i]; + setCurrentRepoIndex(i); + setProcessingMessage(`Analyzing ${repo.fullName} (${i + 1}/${selectedRepos.length})`); + + const repoDir = path.join(tmpDir, repo.owner, repo.name); + + try { + // Clone repo + setProcessingMessage(`Cloning ${repo.fullName}...`); + const repoUrl = `https://${token}@github.com/${repo.fullName}`; + await cloneRepo(repoUrl, repoDir, { shallow: true }); + + // Run readiness report + setProcessingMessage(`Running readiness report for ${repo.fullName}...`); + const report = await runReadinessReport({ repoPath: repoDir }); + + results.push({ + repo: repo.fullName, + report + }); + } catch (error) { + results.push({ + repo: repo.fullName, + error: error instanceof Error ? error.message : "Unknown error" + }); + } + } + + setResults(results); + + // Generate visual report + const html = generateVisualReport({ + reports: results.map(r => ({ + repo: r.repo, + report: r.report!, + error: r.error + })), + title: "Batch AI Readiness Report", + generatedAt: new Date().toISOString() + }); + + const finalOutputPath = outputPath ?? path.join(process.cwd(), "batch-readiness-report.html"); + await fs.writeFile(finalOutputPath, html, "utf8"); + + setStatus("complete"); + setMessage(`Report generated: ${finalOutputPath}`); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to process repositories"); + } finally { + // Clean up temp directory + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + + useInput((input, key) => { + if (key.escape || input.toLowerCase() === "q") { + app.exit(); + return; + } + + if (status === "select-orgs") { + if (key.upArrow) { + setCursorIndex(Math.max(0, cursorIndex - 1)); + } else if (key.downArrow) { + setCursorIndex(Math.min(orgs.length - 1, cursorIndex + 1)); + } else if (input === " ") { + const newSelected = new Set(selectedOrgIndices); + if (newSelected.has(cursorIndex)) { + newSelected.delete(cursorIndex); + } else { + newSelected.add(cursorIndex); + } + setSelectedOrgIndices(newSelected); + } else if (key.return) { + if (selectedOrgIndices.size === 0) { + setMessage("Please select at least one organization"); + return; + } + loadRepos(); + } else if (input.toLowerCase() === "a") { + setSelectedOrgIndices(new Set(orgs.map((_, i) => i))); + } + } + + if (status === "select-repos") { + if (key.upArrow) { + setCursorIndex(Math.max(0, cursorIndex - 1)); + } else if (key.downArrow) { + setCursorIndex(Math.min(repos.length - 1, cursorIndex + 1)); + } else if (input === " ") { + const newSelected = new Set(selectedRepoIndices); + if (newSelected.has(cursorIndex)) { + newSelected.delete(cursorIndex); + } else { + newSelected.add(cursorIndex); + } + setSelectedRepoIndices(newSelected); + } else if (key.return) { + if (selectedRepoIndices.size === 0) { + setMessage("Please select at least one repository"); + return; + } + setStatus("confirm"); + setMessage(`Process ${selectedRepoIndices.size} repositories? (y/n)`); + } else if (input.toLowerCase() === "a") { + setSelectedRepoIndices(new Set(repos.map((_, i) => i))); + } + } + + if (status === "confirm") { + if (input.toLowerCase() === "y") { + processRepos(); + } else if (input.toLowerCase() === "n") { + setStatus("select-repos"); + setMessage(`Select repositories (${repos.length} available)`); + } + } + + if (status === "complete" || status === "error") { + app.exit(); + } + }); + + return ( + + + + Batch Readiness Report + + + + {message} + + + {status === "error" && errorMessage && ( + + {errorMessage} + + )} + + {status === "select-orgs" && ( + + Organizations: + {orgs.slice(0, 20).map((org, i) => ( + + {i === cursorIndex ? ">" : " "} [{selectedOrgIndices.has(i) ? "●" : " "}] {org.name ?? org.login} + + ))} + + [Space] toggle • [A] select all • [Enter] confirm • [Q] quit + + + )} + + {status === "select-repos" && ( + + Repositories ({repos.length}): + {repos.slice(Math.max(0, cursorIndex - 10), Math.min(repos.length, cursorIndex + 10)).map((repo, i) => { + const actualIndex = Math.max(0, cursorIndex - 10) + i; + return ( + + {actualIndex === cursorIndex ? ">" : " "} [{selectedRepoIndices.has(actualIndex) ? "●" : " "}] {repo.fullName} + + ); + })} + + [Space] toggle • [A] select all • [Enter] confirm • [Q] quit + + + )} + + {status === "processing" && ( + + Processing repositories... + {processingMessage} + Progress: {currentRepoIndex + 1}/{Array.from(selectedRepoIndices).length} + + )} + + {status === "complete" && ( + + ✓ Complete! + Total repositories: {results.length} + Successful: {results.filter(r => !r.error).length} + Failed: {results.filter(r => r.error).length} + + )} + + ); +} diff --git a/src/ui/tui.tsx b/src/ui/tui.tsx index ebfaa3f..d759e51 100644 --- a/src/ui/tui.tsx +++ b/src/ui/tui.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState, useRef } from "react"; import { Box, Key, Text, useApp, useInput } from "ink"; import fs from "fs/promises"; import path from "path"; @@ -12,7 +12,7 @@ import { BatchTuiAzure } from "./BatchTuiAzure"; import { getGitHubToken } from "../services/github"; import { getAzureDevOpsToken } from "../services/azureDevops"; import { safeWriteFile } from "../utils/fs"; -import { ReadinessReport, ReadinessCriterionResult, runReadinessReport } from "../services/readiness"; +import { analyzeRepo, RepoApp } from "../services/analyzer"; type Props = { repoPath: string; @@ -23,24 +23,82 @@ type Status = | "intro" | "idle" | "generating" - | "readiness" | "bootstrapping" | "evaluating" | "preview" | "done" | "error" + | "batch-pick" | "batch-github" | "batch-azure" + | "eval-pick" + | "model-pick" + | "generate-pick" + | "generate-app-pick" | "bootstrapEvalCount" | "bootstrapEvalConfirm"; -type EvalConfig = { - instructionFile?: string; - cases: Array<{ id: string; prompt: string; expectation: string }>; - systemMessage?: string; - outputPath?: string; +type LogEntry = { + text: string; + type: "info" | "success" | "error" | "progress"; + time: string; }; +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function useSpinner(active: boolean): string { + const [frame, setFrame] = useState(0); + useEffect(() => { + if (!active) return; + const interval = setInterval(() => { + setFrame((f) => (f + 1) % SPINNER_FRAMES.length); + }, 80); + return () => clearInterval(interval); + }, [active]); + return active ? SPINNER_FRAMES[frame] : ""; +} + +function timestamp(): string { + return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function KeyHint({ k, label }: { k: string; label: string }): React.JSX.Element { + return ( + + {"["} + {k} + {"]"} + {label} + + ); +} + +function Divider({ label }: { label?: string }): React.JSX.Element { + if (label) { + return ( + + {"── "} + {label} + {" ──────────────────────────────────────────"} + + ); + } + return ( + + {"────────────────────────────────────────────────────"} + + ); +} + +const PREFERRED_MODELS = ["claude-sonnet-4.5", "claude-sonnet-4", "gpt-4.1", "gpt-5"]; + +function pickBestModel(available: string[], fallback: string): string { + for (const preferred of PREFERRED_MODELS) { + if (available.includes(preferred)) return preferred; + } + return available[0] || fallback; +} + export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX.Element { const app = useApp(); const [status, setStatus] = useState(skipAnimation ? "idle" : "intro"); @@ -52,22 +110,41 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX const [batchAzureToken, setBatchAzureToken] = useState(null); const [evalCaseCountInput, setEvalCaseCountInput] = useState(""); const [evalBootstrapCount, setEvalBootstrapCount] = useState(null); - const [readinessReport, setReadinessReport] = useState(null); const [availableModels, setAvailableModels] = useState([]); - const [evalModel, setEvalModel] = useState("gpt-4.1"); - const [judgeModel, setJudgeModel] = useState("gpt-4.1"); - const repoLabel = useMemo(() => repoPath, [repoPath]); + const [evalModel, setEvalModel] = useState("claude-sonnet-4.5"); + const [judgeModel, setJudgeModel] = useState("claude-sonnet-4.5"); + const [modelPickTarget, setModelPickTarget] = useState<"eval" | "judge">("eval"); + const [modelCursor, setModelCursor] = useState(0); + const [hasEvalConfig, setHasEvalConfig] = useState(null); + const [activityLog, setActivityLog] = useState([]); + const [generateTarget, setGenerateTarget] = useState<"copilot-instructions" | "agents-md">("copilot-instructions"); + const [generateSavePath, setGenerateSavePath] = useState(""); + const [repoApps, setRepoApps] = useState([]); + const [isMonorepo, setIsMonorepo] = useState(false); + const repoLabel = useMemo(() => path.basename(repoPath), [repoPath]); + const repoFull = useMemo(() => repoPath, [repoPath]); + const isLoading = status === "generating" || status === "bootstrapping" || status === "evaluating"; + const isMenu = status === "model-pick" || status === "eval-pick" || status === "batch-pick" || status === "generate-pick" || status === "generate-app-pick"; + const spinner = useSpinner(isLoading); + + const addLog = (text: string, type: LogEntry["type"] = "info") => { + setActivityLog((prev) => [...prev.slice(-4), { text, type, time: timestamp() }]); + }; const handleAnimationComplete = () => { setStatus("idle"); }; - const cycleModel = (current: string): string => { - if (!availableModels.length) return current; - const index = availableModels.indexOf(current); - const nextIndex = index === -1 ? 0 : (index + 1) % availableModels.length; - return availableModels[nextIndex]; - }; + // Check for eval config and repo structure on mount + useEffect(() => { + const configPath = path.join(repoPath, "primer.eval.json"); + fs.access(configPath).then(() => setHasEvalConfig(true)).catch(() => setHasEvalConfig(false)); + analyzeRepo(repoPath).then((analysis) => { + const apps = analysis.apps ?? []; + setRepoApps(apps); + setIsMonorepo(analysis.isMonorepo ?? false); + }).catch(() => {}); + }, [repoPath]); useEffect(() => { let active = true; @@ -76,8 +153,8 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX if (!active) return; setAvailableModels(models); if (models.length === 0) return; - setEvalModel((current) => (models.includes(current) ? current : models[0])); - setJudgeModel((current) => (models.includes(current) ? current : models[0])); + setEvalModel((current) => (models.includes(current) ? current : pickBestModel(models, current))); + setJudgeModel((current) => (models.includes(current) ? current : pickBestModel(models, current))); }) .catch(() => { if (!active) return; @@ -88,15 +165,62 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX }; }, []); + const doGenerate = async (targetRepoPath: string, savePath: string, target: string): Promise => { + setStatus("generating"); + setMessage(`Generating ${target}...`); + addLog(`Generating ${target}...`, "progress"); + try { + const content = await generateCopilotInstructions({ + repoPath: targetRepoPath, + onProgress: (msg) => setMessage(msg) + }); + if (!content.trim()) { + throw new Error("Copilot SDK returned empty content."); + } + setGeneratedContent(content); + setGenerateSavePath(savePath); + setStatus("preview"); + setMessage("Review the generated content below."); + addLog(`${target} generated — review and save.`, "success"); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Generation failed."; + if (msg.toLowerCase().includes("auth") || msg.toLowerCase().includes("login")) { + setMessage(`${msg} Run 'copilot' then '/login' in a separate terminal.`); + } else { + setMessage(msg); + } + addLog(msg, "error"); + } + }; + const bootstrapEvalConfig = async (count: number, force: boolean): Promise => { const configPath = path.join(repoPath, "primer.eval.json"); try { setStatus("bootstrapping"); setMessage("Generating eval cases with Copilot SDK..."); + addLog("Generating eval scaffold...", "progress"); const config = await generateEvalScaffold({ repoPath, + count, + model: evalModel, + onProgress: (msg) => setMessage(msg) + }); + await safeWriteFile(configPath, JSON.stringify(config, null, 2), force); + setHasEvalConfig(true); + setStatus("idle"); + const msg = `Generated primer.eval.json with ${config.cases.length} cases.`; + setMessage(msg); + addLog(msg, "success"); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Failed to generate eval config."; + setMessage(msg); + addLog(msg, "error"); + } + }; - useInput(async (input: string, key: Key) => { + useInput(async (input: string, key: Key) => { if (status === "intro") { setStatus("idle"); return; @@ -110,21 +234,27 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX if (status === "preview") { if (input.toLowerCase() === "s") { try { - const outputPath = path.join(repoPath, ".github", "copilot-instructions.md"); + const outputPath = generateSavePath || path.join(repoPath, ".github", "copilot-instructions.md"); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, generatedContent, "utf8"); setStatus("done"); - setMessage("Saved to .github/copilot-instructions.md"); + const relPath = path.relative(repoPath, outputPath); + const msg = `Saved to ${relPath}`; + setMessage(msg); + addLog(msg, "success"); setGeneratedContent(""); } catch (error) { setStatus("error"); - setMessage(error instanceof Error ? error.message : "Failed to save."); + const msg = error instanceof Error ? error.message : "Failed to save."; + setMessage(msg); + addLog(msg, "error"); } return; } if (input.toLowerCase() === "d") { setStatus("idle"); setMessage("Discarded generated instructions."); + addLog("Discarded instructions.", "info"); setGeneratedContent(""); return; } @@ -186,92 +316,246 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX return; } - if (input.toLowerCase() === "g") { - setStatus("generating"); - setMessage("Starting generation..."); - try { - const content = await generateCopilotInstructions({ - repoPath, - onProgress: (msg) => setMessage(msg) - }); - if (!content.trim()) { - throw new Error("Copilot SDK returned empty instructions."); + if (status === "generate-pick") { + if (input.toLowerCase() === "c") { + setGenerateTarget("copilot-instructions"); + if (isMonorepo && repoApps.length > 1) { + setStatus("generate-app-pick"); + setMessage("Generate for root or per-app?"); + } else { + const savePath = path.join(repoPath, ".github", "copilot-instructions.md"); + setGenerateSavePath(savePath); + await doGenerate(repoPath, savePath, "copilot-instructions"); } - setGeneratedContent(content); - setStatus("preview"); - setMessage("Review the generated instructions below."); - } catch (error) { - setStatus("error"); - const message = error instanceof Error ? error.message : "Generation failed."; - if (message.toLowerCase().includes("auth") || message.toLowerCase().includes("login")) { - setMessage(`${message} Run 'copilot' then '/login' in a separate terminal.`); + return; + } + if (input.toLowerCase() === "a") { + setGenerateTarget("agents-md"); + if (isMonorepo && repoApps.length > 1) { + setStatus("generate-app-pick"); + setMessage("Generate for root or per-app?"); } else { - setMessage(message); + const savePath = path.join(repoPath, "AGENTS.md"); + setGenerateSavePath(savePath); + await doGenerate(repoPath, savePath, "agents-md"); } + return; } + if (key.escape) { + setStatus("idle"); + setMessage(""); + return; + } + return; } - if (input.toLowerCase() === "b") { - setStatus("generating"); - setMessage("Checking GitHub authentication..."); - const token = await getGitHubToken(); - if (!token) { - setStatus("error"); - setMessage("GitHub auth required. Run 'gh auth login' or set GITHUB_TOKEN."); + if (status === "generate-app-pick") { + if (input.toLowerCase() === "r") { + // Root only + const savePath = generateTarget === "copilot-instructions" + ? path.join(repoPath, ".github", "copilot-instructions.md") + : path.join(repoPath, "AGENTS.md"); + setGenerateSavePath(savePath); + await doGenerate(repoPath, savePath, generateTarget); + return; + } + if (input.toLowerCase() === "a") { + // All apps sequentially + setStatus("generating"); + addLog(`Generating ${generateTarget} for ${repoApps.length} apps...`, "progress"); + let count = 0; + for (const app of repoApps) { + const savePath = generateTarget === "copilot-instructions" + ? path.join(app.path, ".github", "copilot-instructions.md") + : path.join(app.path, "AGENTS.md"); + setMessage(`Generating for ${app.name} (${count + 1}/${repoApps.length})...`); + try { + const content = await generateCopilotInstructions({ + repoPath: app.path, + onProgress: (msg) => setMessage(`${app.name}: ${msg}`) + }); + if (content.trim()) { + await fs.mkdir(path.dirname(savePath), { recursive: true }); + await fs.writeFile(savePath, content, "utf8"); + count++; + addLog(`${app.name}: saved ${path.basename(savePath)}`, "success"); + } + } catch (error) { + const msg = error instanceof Error ? error.message : "Failed."; + addLog(`${app.name}: ${msg}`, "error"); + } + } + setStatus("done"); + setMessage(`Generated ${generateTarget} for ${count}/${repoApps.length} apps.`); + return; + } + // Number to pick a specific app + const num = Number.parseInt(input, 10); + if (Number.isFinite(num) && num >= 1 && num <= repoApps.length) { + const app = repoApps[num - 1]; + const savePath = generateTarget === "copilot-instructions" + ? path.join(app.path, ".github", "copilot-instructions.md") + : path.join(app.path, "AGENTS.md"); + setGenerateSavePath(savePath); + await doGenerate(app.path, savePath, generateTarget); + return; + } + if (key.escape) { + setStatus("generate-pick"); + setMessage("Select what to generate."); return; } - setBatchToken(token); - setStatus("batch-github"); return; } - if (input.toLowerCase() === "z") { - setStatus("generating"); - setMessage("Checking Azure DevOps authentication..."); - const token = getAzureDevOpsToken(); - if (!token) { - setStatus("error"); - setMessage("Azure DevOps PAT required. Set AZURE_DEVOPS_PAT or AZDO_PAT."); + if (status === "model-pick") { + if (key.escape) { + setStatus("idle"); + setMessage(""); + return; + } + if (key.upArrow) { + setModelCursor((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setModelCursor((prev) => Math.min(availableModels.length - 1, prev + 1)); + return; + } + if (key.return) { + const chosen = availableModels[modelCursor]; + if (chosen) { + if (modelPickTarget === "eval") { + setEvalModel(chosen); + addLog(`Eval model → ${chosen}`, "success"); + } else { + setJudgeModel(chosen); + addLog(`Judge model → ${chosen}`, "success"); + } + setStatus("idle"); + setMessage(`${modelPickTarget === "eval" ? "Eval" : "Judge"} model set to ${chosen}`); + } return; } - setBatchAzureToken(token); - setStatus("batch-azure"); return; } - if (input.toLowerCase() === "e") { - const configPath = path.join(repoPath, "primer.eval.json"); - const outputPath = path.join(repoPath, ".primer", "evals", buildTimestampedName("eval-results")); - try { - await fs.access(configPath); - } catch { - setStatus("error"); - setMessage("No primer.eval.json found. Run 'primer eval --init' to create one."); + if (status === "eval-pick") { + if (input.toLowerCase() === "r") { + // Run eval + const configPath = path.join(repoPath, "primer.eval.json"); + const outputPath = path.join(repoPath, ".primer", "evals", buildTimestampedName("eval-results")); + try { + await fs.access(configPath); + } catch { + setStatus("error"); + const msg = "No primer.eval.json found. Press [E] then [I] to create one."; + setMessage(msg); + addLog(msg, "error"); + return; + } + + setStatus("evaluating"); + setMessage("Running evals... (this may take a few minutes)"); + addLog("Running evals...", "progress"); + setEvalResults(null); + setEvalViewerPath(null); + try { + const { results, viewerPath } = await runEval({ + configPath, + repoPath, + model: evalModel, + judgeModel: judgeModel, + outputPath + }); + setEvalResults(results); + setEvalViewerPath(viewerPath ?? null); + const passed = results.filter((r) => r.verdict === "pass").length; + const failed = results.filter((r) => r.verdict === "fail").length; + setStatus("done"); + const msg = `Eval complete: ${passed} pass, ${failed} fail out of ${results.length} cases.`; + setMessage(msg); + addLog(msg, "success"); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Eval failed."; + setMessage(msg); + addLog(msg, "error"); + } return; } + if (input.toLowerCase() === "i") { + setStatus("bootstrapEvalCount"); + setMessage("Enter number of eval cases, then press Enter."); + setEvalCaseCountInput(""); + setEvalBootstrapCount(null); + return; + } + if (key.escape || input.toLowerCase() === "b") { + setStatus("idle"); + setMessage(""); + return; + } + return; + } - setStatus("evaluating"); - setMessage("Running evals... (this may take a few minutes)"); - setEvalResults(null); - setEvalViewerPath(null); - try { - const { results, viewerPath } = await runEval({ - configPath, - repoPath, - model: evalModel, - judgeModel: judgeModel, - outputPath - }); - setEvalResults(results); - setEvalViewerPath(viewerPath ?? null); - const passed = results.filter((r) => r.verdict === "pass").length; - const failed = results.filter((r) => r.verdict === "fail").length; - setStatus("done"); - setMessage(`Eval complete: ${passed} pass, ${failed} fail out of ${results.length} cases.`); - } catch (error) { - setStatus("error"); - setMessage(error instanceof Error ? error.message : "Eval failed."); + if (status === "batch-pick") { + if (input.toLowerCase() === "g") { + setStatus("generating"); + setMessage("Checking GitHub authentication..."); + addLog("Starting batch (GitHub)...", "progress"); + const token = await getGitHubToken(); + if (!token) { + setStatus("error"); + const msg = "GitHub auth required. Run 'gh auth login' or set GITHUB_TOKEN."; + setMessage(msg); + addLog(msg, "error"); + return; + } + setBatchToken(token); + setStatus("batch-github"); + return; + } + if (input.toLowerCase() === "a") { + setStatus("generating"); + setMessage("Checking Azure DevOps authentication..."); + addLog("Starting batch (Azure DevOps)...", "progress"); + const token = getAzureDevOpsToken(); + if (!token) { + setStatus("error"); + const msg = "Azure DevOps PAT required. Set AZURE_DEVOPS_PAT or AZDO_PAT."; + setMessage(msg); + addLog(msg, "error"); + return; + } + setBatchAzureToken(token); + setStatus("batch-azure"); + return; } + if (key.escape || input.toLowerCase() === "b") { + setStatus("idle"); + setMessage(""); + return; + } + return; + } + + if (input.toLowerCase() === "g") { + setStatus("generate-pick"); + setMessage("Select what to generate."); + return; + } + + if (input.toLowerCase() === "b") { + setStatus("batch-pick"); + setMessage("Select batch provider."); + return; + } + + if (input.toLowerCase() === "e") { + setStatus("eval-pick"); + setMessage("Select eval action."); + return; } if (input.toLowerCase() === "m") { @@ -279,9 +563,12 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX setMessage("No Copilot CLI models detected; using defaults."); return; } - const next = cycleModel(evalModel); - setEvalModel(next); - setMessage(`Eval model: ${next}`); + setModelPickTarget("eval"); + setStatus("model-pick"); + setMessage("Pick eval model."); + // Set cursor to current model + const idx = availableModels.indexOf(evalModel); + setModelCursor(idx >= 0 ? idx : 0); return; } @@ -290,56 +577,19 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX setMessage("No Copilot CLI models detected; using defaults."); return; } - const next = cycleModel(judgeModel); - setJudgeModel(next); - setMessage(`Judge model: ${next}`); + setModelPickTarget("judge"); + setStatus("model-pick"); + setMessage("Pick judge model."); + const idx = availableModels.indexOf(judgeModel); + setModelCursor(idx >= 0 ? idx : 0); return; } - - if (input.toLowerCase() === "i") { - setStatus("bootstrapEvalCount"); - setMessage("Enter number of eval cases, then press Enter."); - setEvalCaseCountInput(""); - setEvalBootstrapCount(null); - } - - if (input.toLowerCase() === "r") { - setStatus("readiness"); - setMessage("Running readiness report..."); - setReadinessReport(null); - try { - const report = await runReadinessReport({ repoPath }); - setReadinessReport(report); - setStatus("done"); - setMessage("Readiness report complete."); - } catch (error) { - setStatus("error"); - setMessage(error instanceof Error ? error.message : "Readiness report failed."); - } - } - }); - setEvalCaseCountInput(""); - setEvalBootstrapCount(null); - } - - if (input.toLowerCase() === "r") { - setStatus("readiness"); - setMessage("Running readiness report..."); - setReadinessReport(null); - try { - const report = await runReadinessReport({ repoPath }); - setReadinessReport(report); - setStatus("done"); - setMessage("Readiness report complete."); - } catch (error) { - setStatus("error"); - setMessage(error instanceof Error ? error.message : "Readiness report failed."); - } - } }); - const statusLabel = status === "intro" ? "starting" : status === "idle" ? "ready" : status; - const statusColor = status === "error" ? "red" : status === "done" ? "green" : "yellow"; + const statusIcon = status === "error" ? "✗" : status === "done" ? "✓" : isLoading ? spinner : "●"; + const statusLabel = status === "intro" ? "starting" : status === "idle" ? "ready" : status === "bootstrapEvalCount" ? "input" : status === "bootstrapEvalConfirm" ? "confirm" : status === "eval-pick" ? "eval" : status === "batch-pick" ? "batch" : status === "model-pick" ? "models" : status; + const statusColor = status === "error" ? "red" : status === "done" ? "green" : isLoading ? "yellow" : isMenu ? "magentaBright" : "cyanBright"; + const formatTokens = (result: EvalResult): string => { const withUsage = result.metrics?.withInstructions?.tokenUsage; const withoutUsage = result.metrics?.withoutInstructions?.tokenUsage; @@ -349,11 +599,9 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX return `tokens w/: ${withTotal ?? "n/a"} • w/o: ${withoutTotal ?? "n/a"}`; }; - // Truncate preview to fit terminal const previewLines = generatedContent.split("\n").slice(0, 20); const truncatedPreview = previewLines.join("\n") + (generatedContent.split("\n").length > 20 ? "\n..." : ""); - // Render BatchTui when in batch mode if (status === "batch-github" && batchToken) { return ; } @@ -369,75 +617,211 @@ export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX ) : ( )} + + {/* Status Bar */} - Prime your repo for AI - ● {statusLabel} + Prime your repo for AI + {statusIcon} {statusLabel} - Repo: {repoLabel} - Eval model: {evalModel} • Judge model: {judgeModel} - - - Activity + {/* Context */} + + + + Repo + {repoLabel} + {isMonorepo && monorepo · {repoApps.length} apps} + {repoFull} + + + Model + {evalModel} + • Judge + {judgeModel} + {availableModels.length > 0 && ({availableModels.length} available)} - {message || "Awaiting input."} + + Eval + {hasEvalConfig === null ? ( + checking... + ) : hasEvalConfig ? ( + primer.eval.json found + ) : ( + no eval config — press [I] to create + )} + + + + {/* Activity */} + + + {activityLog.length === 0 && !message ? ( + Awaiting input. + ) : ( + <> + {activityLog.slice(-3).map((entry, i) => ( + + {entry.time} + + {entry.text} + + + ))} + {message && !activityLog.some(e => e.text === message) && ( + + {isLoading ? `${spinner} ` : ""}{message} + + )} + + )} + + {/* Model Picker */} + {status === "model-pick" && availableModels.length > 0 && ( + <> + + + {availableModels.map((model, i) => { + const current = modelPickTarget === "eval" ? evalModel : judgeModel; + const isCurrent = model === current; + const isCursor = i === modelCursor; + return ( + + {isCursor ? "\u276F " : " "} + {model} + {isCurrent && (current)} + + ); + })} + {availableModels.length > 15 && ( + Use {"\u2191\u2193"} to scroll + )} + + + )} + + {/* App picker for monorepo generate */} + {status === "generate-app-pick" && repoApps.length > 0 && ( + <> + + + {repoApps.map((app, i) => ( + + {i + 1} + + {app.name} + {path.relative(repoPath, app.path)} + + ))} + + + )} + + {/* Input: eval case count */} {status === "bootstrapEvalCount" && ( - - Eval case count: {evalCaseCountInput || ""} + + Eval case count: + {evalCaseCountInput || "▍"} )} + + {/* Preview */} {status === "preview" && generatedContent && ( - Preview (.github/copilot-instructions.md): + Preview ({path.relative(repoPath, generateSavePath) || generateTarget}) {truncatedPreview} )} + + {/* Eval Results */} {evalResults && evalResults.length > 0 && ( - - Eval Results: - {evalResults.map((r) => ( - - {r.verdict === "pass" ? "✓" : r.verdict === "fail" ? "✗" : "?"} {r.id}: {r.verdict} (score: {r.score}) • {formatTokens(r)} - - ))} - {evalViewerPath && ( - Trajectory viewer: {evalViewerPath} - )} - - )} - {readinessReport && ( - - Readiness Report: - Level: {readinessReport.achievedLevel || 1} ({levelName(readinessReport.achievedLevel || 1)}) - Monorepo: {readinessReport.isMonorepo ? "yes" : "no"}{readinessReport.apps.length ? ` (${readinessReport.apps.length} apps)` : ""} - Pillars: - {readinessReport.pillars.map((pillar) => ( - = 0.8 ? "green" : "yellow"}> - {pillar.name}: {pillar.passed}/{pillar.total} ({formatPercent(pillar.passRate)}) - - ))} - {topFixes(readinessReport.criteria).length > 0 && ( - <> - Fix first: - {topFixes(readinessReport.criteria).map((fix) => ( - - - {fix.title} ({fix.impact}/{fix.effort}) + <> + + + {evalResults.map((r) => ( + + + {r.verdict === "pass" ? "✓" : r.verdict === "fail" ? "✗" : "?"}{" "} - ))} - - )} - + {r.id} + score:{r.score} • {formatTokens(r)} + + ))} + {evalViewerPath && ( + Viewer: {evalViewerPath} + )} + + )} - + + {/* Commands */} + + {status === "intro" ? ( Press any key to skip animation... ) : status === "preview" ? ( - Keys: [S] Save [D] Discard [Q] Quit + + + + + ) : status === "bootstrapEvalConfirm" ? ( - Keys: [Y] Overwrite [N] Cancel [Q] Quit + + + + + + ) : status === "model-pick" ? ( + + Use + {"\u2191\u2193"} + to navigate, + Enter + to select + + + ) : status === "generate-pick" ? ( + + + + + + ) : status === "generate-app-pick" ? ( + + + + or press + 1 + - + {repoApps.length} + + + + ) : status === "eval-pick" ? ( + + + + + + ) : status === "batch-pick" ? ( + + + + + ) : ( - Keys: [A] Analyze [G] Generate [R] Readiness [E] Eval [I] Init Eval [M] Model [J] Judge [B] Batch [Z] Batch Azure [Q] Quit + + + + + + + + + + + + )} @@ -448,38 +832,3 @@ function buildTimestampedName(baseName: string): string { const stamp = new Date().toISOString().replace(/[:.]/gu, "-"); return `${baseName}-${stamp}.json`; } - -function topFixes(criteria: ReadinessCriterionResult[]): ReadinessCriterionResult[] { - return criteria - .filter((criterion) => criterion.status === "fail") - .sort((a, b) => { - const impactDelta = impactWeight(b.impact) - impactWeight(a.impact); - if (impactDelta !== 0) return impactDelta; - return effortWeight(a.effort) - effortWeight(b.effort); - }) - .slice(0, 5); -} - -function impactWeight(value: "high" | "medium" | "low"): number { - if (value === "high") return 3; - if (value === "medium") return 2; - return 1; -} - -function effortWeight(value: "low" | "medium" | "high"): number { - if (value === "low") return 1; - if (value === "medium") return 2; - return 3; -} - -function formatPercent(value: number): string { - return `${Math.round(value * 100)}%`; -} - -function levelName(level: number): string { - if (level === 2) return "Documented"; - if (level === 3) return "Standardized"; - if (level === 4) return "Optimized"; - if (level === 5) return "Autonomous"; - return "Functional"; -} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..9d7c740 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + target: "node20", + outDir: "dist", + clean: true, + splitting: false, + sourcemap: true, + dts: false, + banner: { + js: "#!/usr/bin/env node", + }, + // Keep node_modules as external — they'll be installed via npm + external: [/^[^./]/], + esbuildOptions(options) { + options.jsx = "automatic"; + }, +});