From 0cdbba32e0c7dc8e6a2137c365f21882f5857c1a Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Mon, 15 Jun 2026 13:24:41 +0800 Subject: [PATCH] feat: add JSON output for AI agents Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com> --- PRD.md | 22 + docs/architecture.md | 18 +- docs/commands.md | 31 +- docs/development-testing.md | 16 + docs/for-me-personal/PROGRESS.md | 13 + docs/for-me-personal/TEST.md | 31 + docs/generated-files.md | 7 +- packages/cli/src/commands/analyze.ts | 32 +- packages/cli/src/commands/ask.ts | 67 +- packages/cli/src/commands/config.ts | 32 +- packages/cli/src/commands/doctor.ts | 61 +- packages/cli/src/commands/init.ts | 32 +- packages/cli/src/index.ts | 19 +- packages/cli/src/utils/devmapFile.ts | 5 + packages/cli/src/utils/errors.ts | 52 +- packages/cli/src/utils/output.ts | 24 + packages/cli/test/init-and-errors.test.ts | 22 + packages/cli/test/json-output.test.ts | 168 +++++ packages/cli/test/package-e2e.mjs | 26 + readme.MD | 743 ++++++++++------------ 20 files changed, 973 insertions(+), 448 deletions(-) create mode 100644 packages/cli/test/json-output.test.ts diff --git a/PRD.md b/PRD.md index 13e8c6c..dbc0349 100644 --- a/PRD.md +++ b/PRD.md @@ -435,6 +435,28 @@ devmap config model auto The override applies to AI-powered commands. `auto` restores the defaults in the model routing table. +### Machine-Readable Output + +All MVP commands support `--json` for AI agents, scripts, and editor +integrations: + +```bash +devmap init --json +devmap analyze --json +devmap ask "how does authentication work?" --json +devmap doctor --json +devmap config model auto --json +``` + +JSON mode rules: + +- stdout contains exactly one valid JSON document +- no ANSI colors, Markdown rendering, box drawing, or progress text +- runtime errors use a stable `{ "status": "error", "error": "...", "hint": "..." }` shape +- `init --json` is non-interactive and requires `GROQ_API_KEY` or existing config +- human-readable output remains the default +- package-manager wrappers may still write their own warnings to stderr + --- ## 10. Generated Files diff --git a/docs/architecture.md b/docs/architecture.md index 5e639b7..6f3090c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -670,7 +670,7 @@ Run devmap init again or check your provider dashboard. --- -## Output Strategy +## Output Strategy CLI output should be: @@ -678,7 +678,21 @@ CLI output should be: * minimal * actionable * friendly for developers -* consistent across commands +* consistent across commands + +### Agent Output + +Every MVP command supports `--json`. JSON mode is implemented at the output +context layer so nested operations, such as `ask` triggering quick analysis, +do not leak human progress text into stdout. + +Rules: + +* emit exactly one JSON document to stdout +* suppress ANSI, Markdown rendering, bullets, and separators +* keep human output as the default +* use structured error objects and preserve non-zero exit codes for thrown failures +* keep command result schemas stable enough for agents and scripts ### Output Should Include diff --git a/docs/commands.md b/docs/commands.md index af9d10c..d6a5348 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -487,7 +487,36 @@ devmap [command] --no-color | `--version` | Print DevMap version | | `--help` | Print help | | `--json` | Output machine-readable JSON | -| `--no-color` | Disable colored terminal output | +| `--no-color` | Disable colored terminal output | + +### JSON Contract + +Use `--json` when DevMap is called by an AI agent, script, CI job, or editor +integration. + +```bash +devmap init --json +devmap analyze --json +devmap analyze --deep --json +devmap ask "where is authentication handled?" --json +devmap doctor --json +devmap config model auto --json +``` + +Contract: + +* stdout contains exactly one JSON document +* ANSI codes and terminal decoration are disabled +* progress sections and Markdown rendering are omitted +* runtime failures return a JSON object with `status`, `error`, and optional `hint` +* `init --json` never prompts and therefore requires `GROQ_API_KEY` or an + existing API key +* package-manager wrapper warnings may appear on stderr and are not part of the + DevMap JSON document + +`analyze --json` returns the project snapshot. `ask --json` returns the answer, +selected files, model, and token usage. `doctor --json` returns diagnostics and +issues as structured fields. --- diff --git a/docs/development-testing.md b/docs/development-testing.md index e69de29..d17ddb4 100644 --- a/docs/development-testing.md +++ b/docs/development-testing.md @@ -0,0 +1,16 @@ +# DevMap Development Testing + +## Agent JSON Output + +Packaged-command verification should include machine-readable output: + +```bash +devmap analyze --json +devmap ask "where is the main entry point?" --json +devmap doctor --json +``` + +Parse stdout directly as JSON and verify that it contains no ANSI codes or +terminal decoration. When invoking through `npm exec` or another package +manager, ignore wrapper-owned stderr warnings and validate DevMap stdout +separately. diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index 9e1a976..cc3f197 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -4,6 +4,19 @@ Terakhir diperbarui: 2026-06-14 ## Update 2026-06-14 +### Agent JSON Output + +- Seluruh command MVP mendukung `--json`. +- stdout JSON hanya berisi satu dokumen valid tanpa ANSI, Markdown renderer, + bullet, separator, atau progress text. +- `analyze --json` mengembalikan snapshot project. +- `ask --json` mengembalikan answer, relevant files, model, dan token usage. +- `doctor --json` mengembalikan diagnostics dan issues terstruktur. +- `config model --json` mengembalikan model state tanpa membocorkan API key. +- `init --json` berjalan non-interaktif dan membutuhkan environment API key + atau existing config. +- Generated `DEVMAP.md` sekarang mengarahkan AI agent memakai `--json`. + ### Model Routing And Config - Default `devmap ask` memakai `llama-3.1-8b-instant`. diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index fb29f4f..738330b 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -70,6 +70,37 @@ pnpm dev:cli config model auto The first command should preserve the existing API key and provider. The last command should restore automatic command-based routing. +## Agent JSON Output + +Focused contract test: + +```powershell +pnpm --filter devmap exec tsx --test test/json-output.test.ts +``` + +Manual source-mode checks: + +```powershell +pnpm dev:cli analyze --json +pnpm dev:cli ask "where scanner" --json +pnpm dev:cli doctor --json +pnpm dev:cli config model auto --json +``` + +Pipe output into a JSON parser: + +```powershell +pnpm dev:cli doctor --json | ConvertFrom-Json +``` + +Expected: + +- parsing succeeds without stripping ANSI; +- stdout contains one JSON document; +- no section header, separator, bullet, or Markdown formatting appears; +- API keys are never included; +- packed package E2E verifies JSON output after tarball installation. + ## Urutan Testing Yang Direkomendasikan Untuk development harian: diff --git a/docs/generated-files.md b/docs/generated-files.md index 23d05b7..f8aaeee 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -22,7 +22,12 @@ devmap init Purpose: -Direct AI agents to DevMap. +Direct AI agents to DevMap. + +Generated `DEVMAP.md` tells AI agents to use command-level `--json` output +instead of parsing decorated terminal text. This applies to `analyze`, `ask`, +and `doctor`, while `init --json` is intended for non-interactive setup with an +environment API key. Rules: diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index ff3d267..6b32ec1 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -6,11 +6,12 @@ import { createProjectMap } from "../analyzers/projectMap.js"; import { inspectSnapshot, saveSnapshot } from "../cache/snapshot.js"; import { readConfig, type DevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; -import { output } from "../utils/output.js"; +import { output, withJsonOutput } from "../utils/output.js"; export type AnalyzeOptions = { deep?: boolean; fresh?: boolean; + json?: boolean; }; export type AnalyzeDependencies = { @@ -23,6 +24,22 @@ export async function analyzeCommand( options: AnalyzeOptions = {}, dependencies: AnalyzeDependencies = {} ): Promise { + if (options.json) { + await withJsonOutput(async () => { + const snapshot = await runAnalyze(target, options, dependencies); + output.json(snapshot); + }); + return; + } + + await runAnalyze(target, options, dependencies); +} + +async function runAnalyze( + target: string, + options: AnalyzeOptions, + dependencies: AnalyzeDependencies +): Promise>> { const projectRoot = resolve(target); output.section("DevMap Analyze"); @@ -34,13 +51,12 @@ export async function analyzeCommand( if (previous.status === "valid" && previous.snapshot.fingerprint === snapshot.fingerprint) { printSnapshot(previous.snapshot, options.deep); output.success("Project is unchanged. Reused existing snapshot."); - await printOrGenerateInterpretation( + return printOrGenerateInterpretation( projectRoot, previous.snapshot, options, dependencies ); - return; } await saveSnapshot(projectRoot, snapshot); @@ -51,7 +67,7 @@ export async function analyzeCommand( output.success("Fresh analysis completed"); } - await printOrGenerateInterpretation(projectRoot, snapshot, options, dependencies); + return printOrGenerateInterpretation(projectRoot, snapshot, options, dependencies); } function printSnapshot( @@ -109,19 +125,19 @@ async function printOrGenerateInterpretation( snapshot: Awaited>, options: AnalyzeOptions, dependencies: AnalyzeDependencies -): Promise { +): Promise>> { if (snapshot.ai && !options.fresh) { output.section("Architecture"); output.markdown(snapshot.ai.architecture); output.note(formatAiMetadata(snapshot.ai.model, snapshot.ai.usage, true)); - return; + return snapshot; } const loadConfig = dependencies.loadConfig ?? readConfig; const config = await loadConfig(); if (!config?.apiKey) { output.note("AI architecture interpretation is not configured. Run devmap init to enable it."); - return; + return snapshot; } const createAiClient = dependencies.createAiClient @@ -160,6 +176,7 @@ async function printOrGenerateInterpretation( interpretation.usage, false )); + return updatedSnapshot; } catch (error) { if (!(error instanceof DevmapError)) { throw error; @@ -170,6 +187,7 @@ async function printOrGenerateInterpretation( output.note(`Tip: ${error.hint}`); } output.note("Static analysis and snapshot were still completed successfully."); + return snapshot; } } diff --git a/packages/cli/src/commands/ask.ts b/packages/cli/src/commands/ask.ts index 24d0082..6010c68 100644 --- a/packages/cli/src/commands/ask.ts +++ b/packages/cli/src/commands/ask.ts @@ -6,9 +6,10 @@ import { inspectSnapshot, isSnapshotStale } from "../cache/snapshot.js"; import { readConfig, type DevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; import { analyzeCommand } from "./analyze.js"; -import { output } from "../utils/output.js"; +import { output, withJsonOutput } from "../utils/output.js"; export type AskDependencies = { + json?: boolean; projectRoot?: string; loadConfig?: () => Promise; createAiClient?: (config: DevmapConfig) => AiClient; @@ -18,10 +19,24 @@ export async function askCommand( questionParts: string[], dependencies: AskDependencies = {} ): Promise { + if (dependencies.json) { + await withJsonOutput(async () => { + output.json(await runAsk(questionParts, dependencies)); + }); + return; + } + + await runAsk(questionParts, dependencies); +} + +async function runAsk( + questionParts: string[], + dependencies: AskDependencies +): Promise> { const question = questionParts.join(" ").trim(); if (!question) { output.error("Please include a question."); - return; + return { status: "error", error: "Please include a question." }; } const projectRoot = dependencies.projectRoot ?? process.cwd(); @@ -39,7 +54,7 @@ export async function askCommand( if (snapshotResult.status !== "valid") { output.error("Could not create snapshot."); - return; + return { status: "error", error: "Could not create snapshot." }; } const snapshot = snapshotResult.snapshot; @@ -53,7 +68,11 @@ export async function askCommand( output.section("Relevant Files"); if (context.files.length === 0) { output.warning("No strong file matches found. Try running devmap analyze --fresh after more code exists."); - return; + return { + status: "no_context", + question, + relevantFiles: [] + }; } for (const file of context.files) { @@ -67,7 +86,14 @@ export async function askCommand( output.warning("AI answering is not configured yet."); output.note("Run devmap init to configure a Groq API key."); printStaticContext(context.files); - return; + return { + status: "static", + question, + relevantFiles: serializeContextFiles(context.files), + answer: null, + model: null, + usage: null + }; } const createAiClient = dependencies.createAiClient @@ -89,6 +115,14 @@ export async function askCommand( output.section("Answer"); output.markdown(answer.content); output.note(formatUsage(answer.model, answer.usage)); + return { + status: "ok", + question, + relevantFiles: serializeContextFiles(context.files), + answer: answer.content, + model: answer.model, + usage: answer.usage ?? null + }; } catch (error) { if (!(error instanceof DevmapError)) { throw error; @@ -100,9 +134,32 @@ export async function askCommand( } output.note("Showing selected source context instead."); printStaticContext(context.files); + return { + status: "fallback", + question, + relevantFiles: serializeContextFiles(context.files), + answer: null, + model, + usage: null, + error: error.message, + hint: error.hint ?? null + }; } } +function serializeContextFiles( + files: Awaited>["files"] +): Array> { + return files.map((file) => ({ + path: file.path, + score: file.score, + reasons: file.reasons, + startLine: file.startLine, + endLine: file.endLine, + truncated: file.truncated + })); +} + function printStaticContext( files: Awaited>["files"] ): void { diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index ea61cb7..4eee3b6 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -3,9 +3,10 @@ import { writeConfig, type DevmapConfig } from "../utils/config.js"; -import { output } from "../utils/output.js"; +import { output, withJsonOutput } from "../utils/output.js"; export type ConfigDependencies = { + json?: boolean; loadConfig?: () => Promise; persistConfig?: (config: DevmapConfig) => Promise; }; @@ -14,10 +15,24 @@ export async function configModelCommand( model: string, dependencies: ConfigDependencies = {} ): Promise { + if (dependencies.json) { + await withJsonOutput(async () => { + output.json(await updateModel(model, dependencies)); + }); + return; + } + + await updateModel(model, dependencies); +} + +async function updateModel( + model: string, + dependencies: ConfigDependencies +): Promise> { const selectedModel = model.trim(); if (!selectedModel) { output.error("Model name cannot be empty."); - return; + return { status: "error", error: "Model name cannot be empty." }; } const loadConfig = dependencies.loadConfig ?? readConfig; @@ -27,7 +42,11 @@ export async function configModelCommand( if (!config) { output.error("DevMap is not configured yet."); output.note("Run devmap init before changing the model."); - return; + return { + status: "error", + error: "DevMap is not configured yet.", + hint: "Run devmap init before changing the model." + }; } await persistConfig({ @@ -40,4 +59,11 @@ export async function configModelCommand( ? "Restored automatic command-based model routing." : `Default model override set to ${selectedModel}.` ); + + return { + status: "ok", + provider: config.provider, + model: selectedModel, + automaticRouting: selectedModel === "auto" + }; } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 67fd449..e7689b7 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -12,7 +12,7 @@ import { detectProjectMetadata } from "../analyzers/projectMetadata.js"; import { inspectSnapshot } from "../cache/snapshot.js"; import { readConfig, type DevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; -import { output } from "../utils/output.js"; +import { output, withJsonOutput } from "../utils/output.js"; const require = createRequire(import.meta.url); const { version: DEVMAP_VERSION } = require("../../package.json") as { @@ -21,6 +21,7 @@ const { version: DEVMAP_VERSION } = require("../../package.json") as { const MINIMUM_NODE_MAJOR = 18; export type DoctorDependencies = { + json?: boolean; projectRoot?: string; loadConfig?: () => Promise; inspectProvider?: ( @@ -32,6 +33,19 @@ export type DoctorDependencies = { export async function doctorCommand( dependencies: DoctorDependencies = {} ): Promise { + if (dependencies.json) { + await withJsonOutput(async () => { + output.json(await runDoctor(dependencies)); + }); + return; + } + + await runDoctor(dependencies); +} + +async function runDoctor( + dependencies: DoctorDependencies +): Promise> { const projectRoot = resolve(dependencies.projectRoot ?? process.cwd()); const loadConfig = dependencies.loadConfig ?? readConfig; const inspectProvider = dependencies.inspectProvider ?? inspectGroqProvider; @@ -47,6 +61,8 @@ export async function doctorCommand( : config?.model; const issues: string[] = []; const nodeSupported = readNodeMajor(process.version) >= MINIMUM_NODE_MAJOR; + let apiKeyStatus = "not configured"; + let modelStatus = selectedModel ?? "not configured"; output.section("DevMap Doctor"); output.keyValue("DevMap", DEVMAP_VERSION); @@ -67,14 +83,16 @@ export async function doctorCommand( issues.push("Run devmap init again to configure Groq."); output.keyValue("API key", "missing"); output.keyValue("Model", selectedModel ?? "not configured"); + apiKeyStatus = "missing"; } else { try { const provider = await inspectProvider(config.apiKey, selectedModel); - output.keyValue("API key", provider.reachable ? "valid" : "unreachable"); - output.keyValue( - "Model", - provider.modelAvailable ? selectedModel : `unavailable: ${selectedModel}` - ); + apiKeyStatus = provider.reachable ? "valid" : "unreachable"; + modelStatus = provider.modelAvailable + ? selectedModel + : `unavailable: ${selectedModel}`; + output.keyValue("API key", apiKeyStatus); + output.keyValue("Model", modelStatus); if (!provider.modelAvailable) { issues.push("Run devmap init or choose an available Groq model."); @@ -85,6 +103,8 @@ export async function doctorCommand( : "Provider diagnostics failed."; output.keyValue("API key", "invalid or unreachable"); output.keyValue("Model", selectedModel); + apiKeyStatus = "invalid or unreachable"; + modelStatus = selectedModel; issues.push(message); } } @@ -99,13 +119,32 @@ export async function doctorCommand( if (issues.length === 0) { output.success("No issues found"); - return; + } else { + output.section("Issues"); + for (const issue of issues) { + output.warning(issue); + } } - output.section("Issues"); - for (const issue of issues) { - output.warning(issue); - } + return { + status: issues.length === 0 ? "ok" : "issues", + devmapVersion: DEVMAP_VERSION, + node: { + version: process.version, + supported: nodeSupported + }, + os: { + platform: platform(), + arch: arch() + }, + project, + provider: config?.provider ?? null, + config: config ? "exists" : "missing", + snapshot: snapshotResult.status, + apiKey: apiKeyStatus, + model: modelStatus, + issues + }; } function readNodeMajor(version: string): number { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 09e6f06..8dafd17 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -12,10 +12,11 @@ import { import { ensureDevmapFile } from "../utils/devmapFile.js"; import { DevmapError } from "../utils/errors.js"; import { ensureDevmapIgnored } from "../utils/gitignore.js"; -import { output } from "../utils/output.js"; +import { output, withJsonOutput } from "../utils/output.js"; import { createPrompt, type Prompt } from "../utils/prompt.js"; export type InitDependencies = { + json?: boolean; projectRoot?: string; prompt?: Prompt; validateApiKey?: (apiKey: string) => Promise; @@ -26,8 +27,23 @@ export type InitDependencies = { }; export async function initCommand(dependencies: InitDependencies = {}): Promise { + if (dependencies.json) { + await withJsonOutput(async () => { + output.json(await runInit(dependencies)); + }); + return; + } + + await runInit(dependencies); +} + +async function runInit( + dependencies: InitDependencies +): Promise> { const projectRoot = resolve(dependencies.projectRoot ?? process.cwd()); - const interactive = dependencies.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); + const interactive = dependencies.json + ? false + : dependencies.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); const loadConfig = dependencies.loadConfig ?? readConfig; const persistConfig = dependencies.persistConfig ?? writeConfig; const existingConfig = await loadConfig(); @@ -79,6 +95,18 @@ export async function initCommand(dependencies: InitDependencies = {}): Promise< output.success(devmapFileCreated ? "Created DEVMAP.md" : "DEVMAP.md already exists"); printAgentsResult(agentsResult); output.step("Next: devmap analyze"); + return { + status: "ok", + provider: "groq", + model: "auto", + framework, + files: { + gitignoreUpdated: ignored, + devmapFileCreated, + agentsFile: agentsResult + }, + next: "devmap analyze" + }; } finally { prompt?.close(); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d18ba5f..3fa1fe4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,7 +19,8 @@ program program .command("init") .description("Initialize DevMap configuration") - .action(() => initCommand()); + .option("--json", "output machine-readable JSON") + .action((options) => initCommand({ json: options.json })); program .command("analyze") @@ -27,26 +28,32 @@ program .argument("[target]", "folder to analyze", ".") .option("--deep", "show a deeper static breakdown") .option("--fresh", "ignore cache and run a fresh analysis") + .option("--json", "output machine-readable JSON") .action((target, options) => analyzeCommand(target, options)); program .command("ask") .description("Find files relevant to a codebase question") .argument("", "question to ask") - .action(askCommand); + .option("--json", "output machine-readable JSON") + .action((question, options) => askCommand(question, { json: options.json })); -program +const configCommand = program .command("config") - .description("Update DevMap configuration") + .description("Update DevMap configuration"); + +configCommand .command("model") .description("Set a model override or restore automatic routing") .argument("", "Groq model ID or auto") - .action(configModelCommand); + .option("--json", "output machine-readable JSON") + .action((model, options) => configModelCommand(model, { json: options.json })); program .command("doctor") .description("Diagnose DevMap setup") - .action(doctorCommand); + .option("--json", "output machine-readable JSON") + .action((options) => doctorCommand({ json: options.json })); await runSafely(async () => { if (process.argv.length === 2) { diff --git a/packages/cli/src/utils/devmapFile.ts b/packages/cli/src/utils/devmapFile.ts index 792aec0..0381f8c 100644 --- a/packages/cli/src/utils/devmapFile.ts +++ b/packages/cli/src/utils/devmapFile.ts @@ -39,14 +39,19 @@ This repository uses DevMap to create reusable project context for developers an \`\`\`bash devmap analyze devmap analyze --deep +devmap analyze --json devmap ask "how does authentication work?" +devmap ask "where is authentication handled?" --json devmap doctor +devmap doctor --json \`\`\` ## Guidance For AI Agents - Read this file before exploring the repository. - Prefer the DevMap snapshot to blind repository-wide exploration. +- Use \`--json\` when calling DevMap programmatically so stdout remains one + parseable JSON document without ANSI or terminal decoration. - Start from entry points and critical files reported by DevMap. - Do not edit generated files inside \`.devmap/\`. - Re-run analysis when the snapshot may be stale. diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 3283f47..332029b 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -11,7 +11,17 @@ export class DevmapError extends Error { } } -export function handleError(error: unknown): number { +export function handleError(error: unknown, json = false): number { + if (json) { + const normalized = normalizeError(error); + output.json({ + status: "error", + error: normalized.message, + hint: normalized.hint ?? null + }); + return normalized.exitCode; + } + if (error instanceof DevmapError) { output.error(error.message); if (error.hint) { @@ -49,8 +59,46 @@ export async function runSafely(action: () => Promise): Promise { try { await action(); } catch (error) { - process.exitCode = handleError(error); + process.exitCode = handleError(error, process.argv.includes("--json")); + } +} + +function normalizeError(error: unknown): { + message: string; + hint?: string; + exitCode: number; +} { + if (error instanceof DevmapError) { + return { + message: error.message, + hint: error.hint, + exitCode: error.exitCode + }; } + + if (isNodeError(error) && error.code === "ENOENT") { + return { + message: "The requested project path could not be found.", + hint: "Check the path and run the command again.", + exitCode: 1 + }; + } + + if (isNodeError(error) && (error.code === "EACCES" || error.code === "EPERM")) { + return { + message: "DevMap does not have permission to access a required file.", + hint: "Check folder permissions or run DevMap from a writable project directory.", + exitCode: 1 + }; + } + + return { + message: error instanceof Error + ? error.message + : "DevMap could not complete the command due to an unknown error.", + hint: "Run devmap doctor --json and include its output when reporting this issue.", + exitCode: 1 + }; } function isNodeError(error: unknown): error is NodeJS.ErrnoException { diff --git a/packages/cli/src/utils/output.ts b/packages/cli/src/utils/output.ts index eb02c3a..29f429e 100644 --- a/packages/cli/src/utils/output.ts +++ b/packages/cli/src/utils/output.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { renderTerminalMarkdown } from "./markdownTerminal.js"; export const theme = { @@ -11,6 +12,15 @@ export const theme = { }; const LINE = "─".repeat(56); +const outputMode = new AsyncLocalStorage<"human" | "json">(); + +export function withJsonOutput(action: () => Promise): Promise { + return outputMode.run("json", action); +} + +function isJsonOutput(): boolean { + return outputMode.getStore() === "json"; +} function color(value: string | number, tone: keyof typeof theme): string { return `${theme[tone]}${value}${theme.reset}`; @@ -18,46 +28,60 @@ function color(value: string | number, tone: keyof typeof theme): string { export const output = { section(title: string): void { + if (isJsonOutput()) return; console.log(`\n${color(title, "aqua")}`); console.log(color(LINE, "gray")); }, step(message: string): void { + if (isJsonOutput()) return; console.log(`${color(">", "aqua")} ${message}`); }, success(message: string): void { + if (isJsonOutput()) return; console.log(`${color("OK", "green")} ${message}`); }, warning(message: string): void { + if (isJsonOutput()) return; console.log(`${color("WARN", "yellow")} ${message}`); }, error(message: string): void { + if (isJsonOutput()) return; console.error(`${color("ERROR", "red")} ${message}`); }, keyValue(key: string, value: string | number): void { + if (isJsonOutput()) return; console.log(`${color(key.padEnd(18), "gray")} ${color(value, "aqua")}`); }, item(value: string): void { + if (isJsonOutput()) return; console.log(`${color("•", "aqua")} ${value}`); }, note(message: string): void { + if (isJsonOutput()) return; console.log(color(message, "gray")); }, codeBlock(content: string): void { + if (isJsonOutput()) return; console.log(color(content, "gray")); }, markdown(content: string): void { + if (isJsonOutput()) return; console.log(renderTerminalMarkdown(content, { width: process.stdout.columns ?? 80, colors: true })); + }, + + json(value: unknown): void { + console.log(JSON.stringify(value)); } }; diff --git a/packages/cli/test/init-and-errors.test.ts b/packages/cli/test/init-and-errors.test.ts index edc2152..6788432 100644 --- a/packages/cli/test/init-and-errors.test.ts +++ b/packages/cli/test/init-and-errors.test.ts @@ -24,6 +24,7 @@ test("DEVMAP.md contains workflow and AI-agent guidance", () => { assert.match(content, /Detected framework: nextjs/); assert.match(content, /devmap analyze/); assert.match(content, /Guidance For AI Agents/); + assert.match(content, /--json/); assert.match(content, /\.devmap\/snapshot\.json/); assert.match(content, /Never commit API keys/); }); @@ -307,6 +308,27 @@ test("global error handler translates missing paths into actionable output", () } }); +test("global error handler emits parseable JSON for machine output", () => { + const logs: string[] = []; + const originalLog = console.log; + + console.log = (...values: unknown[]) => logs.push(values.join(" ")); + + try { + assert.equal( + handleError(new DevmapError("Readable failure.", "Use doctor."), true), + 1 + ); + assert.deepEqual(JSON.parse(logs.join("\n")), { + status: "error", + error: "Readable failure.", + hint: "Use doctor." + }); + } finally { + console.log = originalLog; + } +}); + type FakePrompt = Prompt & { closed: boolean; }; diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts new file mode 100644 index 0000000..1c2c017 --- /dev/null +++ b/packages/cli/test/json-output.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { AiClient } from "../src/ai/types.js"; +import { createProjectMap } from "../src/analyzers/projectMap.js"; +import { saveSnapshot } from "../src/cache/snapshot.js"; +import { analyzeCommand } from "../src/commands/analyze.js"; +import { askCommand } from "../src/commands/ask.js"; +import { configModelCommand } from "../src/commands/config.js"; +import { doctorCommand } from "../src/commands/doctor.js"; +import { initCommand } from "../src/commands/init.js"; + +test("analyze --json emits one parseable snapshot document", async () => { + const projectRoot = await createProject("json-analyze"); + + try { + const output = await captureStdout(() => analyzeCommand( + projectRoot, + { fresh: true, json: true }, + { loadConfig: async () => null } + )); + const payload = parseSingleJson(output); + + assert.equal(payload.project.name, "json-analyze"); + assert.ok(payload.fileIndex["index.ts"]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("ask --json emits answer, relevant files, model, and usage", async () => { + const projectRoot = await createProject("json-ask"); + const snapshot = await createProjectMap(projectRoot); + await saveSnapshot(projectRoot, snapshot); + const client: AiClient = { + async complete(request) { + return { + content: "The entry point is index.ts.", + model: request.model, + usage: { + promptTokens: 20, + completionTokens: 8, + totalTokens: 28 + } + }; + } + }; + + try { + const output = await captureStdout(() => askCommand( + ["where", "is", "the", "entry", "point"], + { + json: true, + projectRoot, + loadConfig: async () => ({ + provider: "groq", + apiKey: "gsk_fixture", + model: "auto" + }), + createAiClient: () => client + } + )); + const payload = parseSingleJson(output); + + assert.equal(payload.status, "ok"); + assert.equal(payload.answer, "The entry point is index.ts."); + assert.equal(payload.model, "llama-3.1-8b-instant"); + assert.equal(payload.usage.totalTokens, 28); + assert.ok(Array.isArray(payload.relevantFiles)); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("doctor and config JSON outputs contain no formatting noise", async () => { + const projectRoot = await createProject("json-doctor"); + let savedModel = ""; + + try { + const doctorOutput = await captureStdout(() => doctorCommand({ + json: true, + projectRoot, + loadConfig: async () => null + })); + const configOutput = await captureStdout(() => configModelCommand( + "openai/gpt-oss-120b", + { + json: true, + loadConfig: async () => ({ + provider: "groq", + apiKey: "gsk_fixture", + model: "auto" + }), + persistConfig: async (config) => { + savedModel = config.model; + } + } + )); + + assert.equal(parseSingleJson(doctorOutput).status, "issues"); + assert.equal(parseSingleJson(configOutput).model, "openai/gpt-oss-120b"); + assert.equal(savedModel, "openai/gpt-oss-120b"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("init --json is non-interactive and returns setup metadata", async () => { + const projectRoot = await createProject("json-init"); + + try { + const output = await captureStdout(() => initCommand({ + json: true, + projectRoot, + environmentApiKey: "gsk_fixture", + loadConfig: async () => null, + persistConfig: async () => undefined, + validateApiKey: async () => undefined + })); + const payload = parseSingleJson(output); + + assert.equal(payload.status, "ok"); + assert.equal(payload.provider, "groq"); + assert.equal(payload.next, "devmap analyze"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +async function createProject(name: string): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-json-output-")); + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name }), + "utf8" + ); + await writeFile( + join(projectRoot, "index.ts"), + "export function start() { return true; }\n", + "utf8" + ); + return projectRoot; +} + +async function captureStdout(action: () => Promise): Promise { + const logs: string[] = []; + const originalLog = console.log; + const originalError = console.error; + + console.log = (...values: unknown[]) => logs.push(values.join(" ")); + console.error = (...values: unknown[]) => logs.push(values.join(" ")); + + try { + await action(); + return logs.join("\n"); + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +function parseSingleJson(value: string): any { + assert.doesNotMatch(value, /\x1b\[|─|•/); + assert.equal(value.trim().split(/\r?\n/).length, 1); + return JSON.parse(value); +} diff --git a/packages/cli/test/package-e2e.mjs b/packages/cli/test/package-e2e.mjs index af4523b..09753fc 100644 --- a/packages/cli/test/package-e2e.mjs +++ b/packages/cli/test/package-e2e.mjs @@ -94,16 +94,37 @@ try { const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")); assert.equal(snapshot.project.framework, expectedFramework); + const analyzeJson = parseJsonOutput(await runDevmap(projectRoot, [ + "analyze", + "--fresh", + "--json" + ])); + assert.equal(analyzeJson.project.framework, expectedFramework); + const ask = await runDevmap(projectRoot, [ "ask", "Where is the main application entry point?" ]); assert.match(stripAnsi(ask.stdout), /Relevant Files/); + const askJson = parseJsonOutput(await runDevmap(projectRoot, [ + "ask", + "Where is the main application entry point?", + "--json" + ])); + assert.equal(askJson.status, "static"); + assert.ok(Array.isArray(askJson.relevantFiles)); + const doctor = await runDevmap(projectRoot, ["doctor"]); const doctorOutput = stripAnsi(doctor.stdout); assert.match(doctorOutput, /DevMap Doctor/); assert.match(doctorOutput, /Config\s+missing/i); + + const doctorJson = parseJsonOutput(await runDevmap(projectRoot, [ + "doctor", + "--json" + ])); + assert.equal(doctorJson.config, "missing"); } } finally { await rm(temporaryRoot, { recursive: true, force: true }); @@ -153,3 +174,8 @@ function quoteWindowsArgument(value) { function stripAnsi(value) { return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); } + +function parseJsonOutput(result) { + assert.doesNotMatch(result.stdout, /\u001B\[|─|•/); + return JSON.parse(result.stdout); +} diff --git a/readme.MD b/readme.MD index d5e037d..75a9630 100644 --- a/readme.MD +++ b/readme.MD @@ -1,408 +1,335 @@ -# devmap - -> Understand any codebase in minutes, not days. - ---- - -[Demo GIF placeholder — record with VHS before publishing] - ---- - -## The Problem - -Modern AI tools generate code faster than developers can understand it. - -You clone a repository — hundreds of files stare back at you. - -You join a project halfway through development — nobody has time to explain the architecture. - -You return to an old project after months — and spend hours remembering how everything fits together. - -You ask AI for help — and the AI starts exploring the repository from scratch again. - -Reading every file manually does not scale. - ---- - -## The Solution - -DevMap analyzes your project using static analysis and AI to generate a reusable project snapshot. - -The snapshot contains: - -* Architecture overview -* Entry points -* Critical files -* Routes and APIs -* External services -* Database information -* Detected features -* Project relationships - -Instead of repeatedly exploring the codebase, developers and AI tools can start with a shared understanding of the project. - -One analysis. - -Reusable context. - -Any codebase. - ---- - -## How It Works - -DevMap works in three stages. - -```txt -devmap init - → configures provider - → generates DEVMAP.md - → prepares project - -devmap analyze - → runs static analysis - → discovers architecture - → generates snapshot.json - -snapshot.json - → reusable project context - → used by developers - → used by AI agents - → used by DevMap commands -``` - -### Generated Files - -| File | Role | -| ----------------------- | -------------------- | -| `DEVMAP.md` | DevMap instructions | -| `AGENTS.md` | AI agent entry point | -| `.devmap/snapshot.json` | Core project context | - -The snapshot is the primary output of DevMap. - -Everything else builds on top of it. - ---- - -## Quick Start - -```bash -# Install -npm install -g devmap - -# Setup -devmap init - -# Generate project context -devmap analyze -``` - -DevMap generates: - -```txt -.devmap/snapshot.json -``` - -This snapshot becomes a reusable source of project context for: - -* Developers -* AI agents -* Future DevMap commands - -### What comes next? - -```bash -# Generate reusable project context -devmap analyze - -# Verify your setup -devmap doctor - -# Explore the architecture -devmap ask "explain the main architecture" - -# Future: generate onboarding guide -devmap onboard -``` - - -## Example Output - -```txt -PROJECT devnote -FRAMEWORK Next.js -LANGUAGE TypeScript - -Entry Points -→ app/layout.tsx -→ middleware.ts - -Critical Files -→ lib/db.ts -→ lib/auth.ts - -External Services -→ Neon -→ Google OAuth - -Architecture -This is a full-stack Next.js application. Authentication is handled -server-side. Database access is centralized through the data layer. - -Snapshot saved: -.devmap/snapshot.json - -Ready for: -→ AI agents -→ DevMap commands -→ Future onboarding generation -``` - ---- - -## Why DevMap Exists - -Most AI tools rebuild project understanding every session. - -Without DevMap: - -```txt -Repository - ↓ -AI explores files - ↓ -AI rebuilds context - ↓ -Task begins -``` - -With DevMap: - -```txt -Repository - ↓ -DevMap Analysis - ↓ -snapshot.json - ↓ -Reusable Context - ↓ -Task begins -``` - -The goal is not to replace AI. - -The goal is to eliminate repeated project exploration. - ---- - -## For AI Agents - -If you use: - -* Claude Code -* OpenAI Codex -* Gemini CLI -* Cursor -* Windsurf -* Aider -* GitHub Copilot -* Amazon Q - -DevMap provides reusable project context. - -Without DevMap: - -* AI explores repositories from scratch -* Context is rebuilt every session -* Tokens are spent on discovery before solving problems - -With DevMap: - -* AI reads `AGENTS.md` -* AI is directed to `DEVMAP.md` -* AI loads `.devmap/snapshot.json` -* AI starts with project context already available - -```txt -AI Agent - ↓ -AGENTS.md - ↓ -DEVMAP.md - ↓ -snapshot.json - ↓ -work immediately -``` - -One snapshot. - -Every tool. - -No repeated explanations. - ---- - -## Supported Stacks - -### MVP - -* Next.js -* Express - -### Planned - -* React -* NestJS -* Laravel -* Nuxt - ---- - -## Documentation - -Detailed documentation: - -* [PRD.md](./PRD.md) -* [docs/commands.md](./docs/commands.md) -* [docs/architecture.md](./docs/architecture.md) -* [docs/generated-files.md](./docs/generated-files.md) -* [docs/design.md](./docs/design.md) -* [docs/benchmarking.md](./docs/benchmarking.md) -* [docs/development-testing.md](./docs/development-testing.md) -* [docs/roadmap.md](./docs/roadmap.md) -* [docs/contributing.md](./docs/contributing.md) - ---- - -## Roadmap - -### MVP - -* [x] `devmap init` -* [x] `devmap analyze` -* [x] `devmap ask` -* [x] `devmap doctor` - -### Next - -* [ ] `devmap onboard` -* [ ] `devmap features` -* [ ] OpenAI provider -* [ ] Gemini provider - -### Later - -* [ ] `devmap explain` -* [ ] `devmap flow` -* [ ] `devmap docs` -* [ ] Local AI mode -* [ ] VS Code Extension - -See: - -```txt -docs/roadmap.md -``` - -for details. - ---- - -## Vision - -DevMap is not an AI coding assistant. - -AI coding assistants help developers write code. - -DevMap helps developers understand code that already exists. - -They are complementary, not competitors. - -Use DevMap to understand the codebase. - -Use AI coding assistants to modify it. - -> DevMap is the context layer between developers, AI agents, and unfamiliar codebases. - ---- - -## AI Provider Setup - -DevMap is free and open source. - -AI features require a provider API key. - -| Provider | Status | -| -------- | ------- | -| Groq | MVP | -| OpenAI | Planned | -| Gemini | Planned | - -API keys are stored locally: - -```txt -~/.devmap/config.json -``` - -DevMap does not require a backend server. - -Requests go directly from your machine to the selected provider. - ---- - -## Installation - -```bash -# npm -npm install -g devmap - -# pnpm -pnpm add -g devmap - -# run without installing -npx devmap analyze -``` - -Requirements: - -```txt -Node.js 18+ -``` - ---- - -## Contributing - -DevMap is open source and welcomes contributions. - -```bash -git clone https://github.com/itsflaid/devmap -cd devmap - -npm install - -npm link - -devmap analyze -``` - -Before contributing: - -1. Read `PRD.md` -2. Read `docs/architecture.md` -3. Read `docs/contributing.md` - ---- - -## License - -MIT License - -Use it, fork it, improve it, and build on top of it. - ---- - -Built by [@itsflaid](https://github.com/itsflaid) +# devmap + +> The first command you run after git clone. + +AI should spend tokens solving problems, not rediscovering your codebase. + +Built by [Fadil (@itsflaid)](https://github.com/itsflaid) + +--- + +[Demo GIF placeholder — record with VHS before publishing] + +--- + +## The Problem + +You ask AI for help. + +It starts exploring the repository. Again. + +You switch tools. It starts again. + +New session. Again. + +You join a project halfway through — nobody has time to explain the architecture. + +Every AI agent rebuilds context from scratch before real work begins. + +--- + +## The Solution + +DevMap analyzes your project using static analysis and AI to generate a reusable project snapshot. + +The snapshot contains: + +* Architecture overview +* Entry points +* Critical files +* Routes and APIs +* External services +* Database information +* Detected features +* Project relationships + +One analysis. Reusable context. Any codebase. + +Without DevMap: + +```txt +Repository + ↓ +AI explores files + ↓ +AI rebuilds context + ↓ +Task begins +``` + +With DevMap: + +```txt +Repository + ↓ +devmap analyze + ↓ +snapshot.json + ↓ +Reusable context + ↓ +Task begins immediately +``` + +--- + +## How It Works + +```txt +devmap init + → configures provider + → generates DEVMAP.md + → prepares project + +devmap analyze + → runs static analysis (80%) + → AI interprets structure (20%) + → generates snapshot.json + +snapshot.json + → reusable project context + → used by developers + → used by AI agents + → used by DevMap commands +``` + +### Generated Files + +| File | Role | +| ----------------------- | -------------------- | +| `DEVMAP.md` | DevMap instructions | +| `AGENTS.md` | AI agent entry point | +| `.devmap/snapshot.json` | Core project context | + +The snapshot is the primary output of DevMap. + +Everything else builds on top of it. + +--- + +## Quick Start + +```bash +# Install +npm install -g devmap + +# Setup +devmap init + +# Generate project context +devmap analyze + +# Verify your setup +devmap doctor + +# Ask questions about your codebase +devmap ask "explain the main architecture" +devmap ask "where is the auth logic?" +devmap ask "what external services does this use?" +``` + +--- + +## Example Output + +```txt +PROJECT devnote +FRAMEWORK Next.js +LANGUAGE TypeScript + +Entry Points +→ app/layout.tsx +→ middleware.ts + +Critical Files +→ lib/db.ts +→ lib/auth.ts + +External Services +→ Neon +→ Google OAuth + +Architecture +This is a full-stack Next.js application. Authentication is handled +server-side. Database access is centralized through the data layer. + +Snapshot saved: +.devmap/snapshot.json +``` + +--- + +## For AI Agents + +If you use Claude Code, OpenAI Codex, Gemini CLI, Cursor, Windsurf, Aider, GitHub Copilot, or Amazon Q — DevMap provides reusable project context that works across all of them. + +Without DevMap: + +* AI explores repositories from scratch every session +* Tokens are spent on discovery before solving problems +* Context is lost when you switch tools + +With DevMap: + +```txt +AI Agent + ↓ +AGENTS.md + ↓ +DEVMAP.md + ↓ +snapshot.json + ↓ +work immediately +``` + +One snapshot. Every tool. No repeated explanations. + +> Benchmark results coming — with and without DevMap, same task, measured token usage. +> See [docs/benchmarking.md](./docs/benchmarking.md) for methodology. + +--- + +## Vision + +DevMap is not an AI coding assistant. + +AI coding assistants help developers write code. +DevMap helps developers understand code that already exists. + +They are complementary, not competitors. + +Use DevMap to understand the codebase. +Use AI coding assistants to modify it. + +> DevMap is the context layer between developers, AI agents, and unfamiliar codebases. + +--- + +## Supported Stacks + +### MVP + +* Next.js +* Express + +### Planned + +* React +* NestJS +* Laravel +* Nuxt + +--- + +## AI Provider Setup + +DevMap is free and open source. + +AI features require a provider API key. DevMap uses Groq by default — analysis runs on free-tier infrastructure. + +| Provider | Status | +| -------- | ------- | +| Groq | MVP | +| OpenAI | Planned | +| Gemini | Planned | + +API keys are stored locally: + +```txt +~/.devmap/config.json +``` + +DevMap does not require a backend server. + +Requests go directly from your machine to the selected provider. + +--- + +## Installation + +```bash +# npm +npm install -g devmap + +# pnpm +pnpm add -g devmap + +# run without installing +npx devmap analyze +``` + +Requirements: + +```txt +Node.js 18+ +``` + +--- + +## Roadmap + +### MVP + +* [x] `devmap init` +* [x] `devmap analyze` +* [x] `devmap ask` +* [x] `devmap doctor` + +### Next + +* [ ] `devmap onboard` +* [ ] `devmap features` +* [ ] OpenAI provider +* [ ] Gemini provider + +### Later + +* [ ] `devmap explain` +* [ ] `devmap flow` +* [ ] `devmap docs` +* [ ] Local AI mode +* [ ] VS Code Extension + +See [docs/roadmap.md](./docs/roadmap.md) for details. + +--- + +## Documentation + +* [PRD.md](./PRD.md) +* [docs/commands.md](./docs/commands.md) +* [docs/architecture.md](./docs/architecture.md) +* [docs/generated-files.md](./docs/generated-files.md) +* [docs/design.md](./docs/design.md) +* [docs/benchmarking.md](./docs/benchmarking.md) +* [docs/roadmap.md](./docs/roadmap.md) +* [CONTRIBUTING.md](./CONTRIBUTING.md) + +--- + +## Contributing + +DevMap is open source and welcomes contributions. + +```bash +git clone https://github.com/itsflaid/devmap +cd devmap + +npm install +npm link + +devmap analyze +``` + +Before contributing: + +1. Read `PRD.md` +2. Read `docs/architecture.md` +3. Read `CONTRIBUTING.md` + +--- + +## License + +MIT License — use it, fork it, improve it, build on top of it.