From 0dcf7c5cf2e069f87d78a7654945706577213ffd Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Mon, 2 Nov 2026 06:46:19 +0800 Subject: [PATCH 1/6] fix(routes): detect tRPC procedures built on any base procedure Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRWEEb5zotxkY7QpbA2 --- .../analyzers/detectors/trpcRouteDetector.ts | 19 ++++----- packages/cli/test/framework-routes.test.ts | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts b/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts index c400309..0643ee1 100644 --- a/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts +++ b/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts @@ -121,21 +121,22 @@ function parseRouterFile( const routerBody = content.slice(startIdx - 1, endIdx); - // Extract procedures by finding "name: publicProcedure" then scanning forward for .query or .mutation - const procStartPattern = /(\w+)\s*:\s*publicProcedure\b/g; + // A procedure starts at "name: Procedure" (publicProcedure, protectedProcedure, + // t.procedure, ...) and its kind is the first .query/.mutation before the next procedure. + const procStartPattern = /(\w+)\s*:\s*(?:\w+\.)?\w*[pP]rocedure\b/g; + const starts = [...routerBody.matchAll(procStartPattern)]; const procedures: Array<{ name: string; kind: ProcedureKind }> = []; - let procStartMatch = procStartPattern.exec(routerBody); - while (procStartMatch) { - const procName = procStartMatch[1]; - const afterStart = routerBody.slice(procStartMatch.index + procStartMatch[0].length); - const kindMatch = afterStart.match(/\.(query|mutation)\s*\(/); + for (let index = 0; index < starts.length; index++) { + const start = starts[index]; + const segmentStart = (start.index ?? 0) + start[0].length; + const segmentEnd = starts[index + 1]?.index ?? routerBody.length; + const kindMatch = routerBody.slice(segmentStart, segmentEnd).match(/\.(query|mutation)\s*\(/); if (kindMatch) { procedures.push({ - name: procName, + name: start[1], kind: kindMatch[1].toUpperCase() as ProcedureKind }); } - procStartMatch = procStartPattern.exec(routerBody); } // Extract sub-router references: "subRouter" used as values diff --git a/packages/cli/test/framework-routes.test.ts b/packages/cli/test/framework-routes.test.ts index 8c2112f..108321c 100644 --- a/packages/cli/test/framework-routes.test.ts +++ b/packages/cli/test/framework-routes.test.ts @@ -559,6 +559,45 @@ test("detectRoutes extracts tRPC procedures as routes", () => { assert.ok(routes.some((r) => r.path === "/trpc/message.create" && r.methods?.includes("MUTATION"))); }); +test("detectRoutes reads tRPC procedures built on custom base procedures", () => { + const files = [ + createScannedFile("package.json", JSON.stringify({ + dependencies: { next: "^15.0.0", "@trpc/server": "^11.0.0" } + })), + createScannedFile("src/server/trpc.ts", [ + 'import { initTRPC } from "@trpc/server";', + "const t = initTRPC.create();", + "export const router = t.router;", + "export const publicProcedure = t.procedure;", + "export const protectedProcedure = t.procedure.use(({ next }) => next());", + "export const rateLimitedProcedure = protectedProcedure.use(({ next }) => next());" + ].join("\n")), + createScannedFile("src/server/routers/room.ts", [ + 'import { router, protectedProcedure, rateLimitedProcedure } from "../trpc";', + "export const roomRouter = router({", + " list: protectedProcedure.query(async ({ ctx }) => ctx.prisma.room.findMany()),", + " watch: protectedProcedure.subscription(async function* () {}),", + " create: rateLimitedProcedure", + " .input(z.object({ name: z.string() }))", + " .mutation(async ({ ctx, input }) => ctx.prisma.room.create({ data: input })),", + " inline: t.procedure.query(() => []),", + "});" + ].join("\n")), + createScannedFile("src/server/routers/_app.ts", [ + 'import { router } from "../trpc";', + 'import { roomRouter } from "./room";', + "export const appRouter = router({ room: roomRouter });" + ].join("\n")) + ]; + + const routes = detectRoutes(files, []); + const byPath = new Map(routes.map((route) => [route.path, route.methods])); + assert.deepEqual(byPath.get("/trpc/room.list"), ["QUERY"]); + assert.deepEqual(byPath.get("/trpc/room.create"), ["MUTATION"]); + assert.deepEqual(byPath.get("/trpc/room.inline"), ["QUERY"]); + assert.equal(byPath.has("/trpc/room.watch"), false); +}); + test("tRPC routes surface as features through project map", async () => { try { const snapshot = await createProjectMap(trpcFixture); From 0b175c6b78a70e5d01e06edd022fd0ee993fc505 Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Mon, 2 Nov 2026 06:47:02 +0800 Subject: [PATCH 2/6] fix(metadata): classify VS Code extension packages as vscode-extension Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRWEEb5zotxkY7QpbA2 --- .../src/analyzers/pipeline/projectMetadata.ts | 24 +++++++++++++++--- packages/cli/src/cache/agentNavigation.ts | 1 + packages/cli/test/detector-fixes.test.ts | 25 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/analyzers/pipeline/projectMetadata.ts b/packages/cli/src/analyzers/pipeline/projectMetadata.ts index bf0aa9e..2825fbe 100644 --- a/packages/cli/src/analyzers/pipeline/projectMetadata.ts +++ b/packages/cli/src/analyzers/pipeline/projectMetadata.ts @@ -12,7 +12,7 @@ import { isArchitectureSource } from "../graph/index.js"; export type ProjectLanguage = "typescript" | "javascript" | "mixed" | "unknown"; export type PackageManager = "pnpm" | "npm" | "yarn" | "bun" | "unknown"; -export type ProjectType = "node-cli" | "web-app" | "api-service" | "library" | "unknown"; +export type ProjectType = "node-cli" | "web-app" | "api-service" | "library" | "vscode-extension" | "unknown"; export type WorkspaceType = "monorepo" | "single-package"; @@ -60,6 +60,8 @@ type PackageManifest = { bin?: unknown; main?: unknown; exports?: unknown; + engines?: unknown; + contributes?: unknown; workspaces?: unknown; packageManager?: unknown; dependencies: Record; @@ -79,6 +81,8 @@ function readPackageManifests(files: ScannedFile[]): PackageManifest[] { bin: parsed.bin, main: parsed.main, exports: parsed.exports, + engines: parsed.engines, + contributes: parsed.contributes, workspaces: parsed.workspaces, packageManager: parsed.packageManager, dependencies: isStringRecord(parsed.dependencies) ? parsed.dependencies : {}, @@ -171,10 +175,16 @@ function detectProjectTypes( if (manifests.some((manifest) => manifest.bin)) { types.add("node-cli"); } + if (manifests.some(isVscodeExtension)) { + types.add("vscode-extension"); + } // A package that ships an app framework with its default "main" is an app, - // not a library. + // not a library. An editor extension's "main" is its activation entry, not a public API. if (manifests.some((manifest) => - (manifest.exports || manifest.main) && !manifest.bin && !declaresAnyFramework(manifest) + (manifest.exports || manifest.main) + && !manifest.bin + && !declaresAnyFramework(manifest) + && !isVscodeExtension(manifest) )) { types.add("library"); } @@ -182,6 +192,14 @@ function detectProjectTypes( return types.size > 0 ? [...types] : ["unknown"]; } +function isVscodeExtension(manifest: PackageManifest): boolean { + const engines = manifest.engines; + const declaresVscodeEngine = typeof engines === "object" + && engines !== null + && "vscode" in engines; + return declaresVscodeEngine || Boolean(manifest.contributes); +} + function selectPrimaryManifest( manifests: PackageManifest[], projectTypes: ProjectType[] diff --git a/packages/cli/src/cache/agentNavigation.ts b/packages/cli/src/cache/agentNavigation.ts index 5f03c09..6347e8a 100644 --- a/packages/cli/src/cache/agentNavigation.ts +++ b/packages/cli/src/cache/agentNavigation.ts @@ -273,6 +273,7 @@ function describeProjectKind(snapshot: ProjectMap): string { if (types.includes("web-app")) kinds.push("web application"); if (types.includes("api-service")) kinds.push("API service"); if (types.includes("library")) kinds.push("reusable library"); + if (types.includes("vscode-extension")) kinds.push("VS Code extension"); if (kinds.length === 0) return workspace; if (kinds.length === 1) { diff --git a/packages/cli/test/detector-fixes.test.ts b/packages/cli/test/detector-fixes.test.ts index d2f4019..ad92b4e 100644 --- a/packages/cli/test/detector-fixes.test.ts +++ b/packages/cli/test/detector-fixes.test.ts @@ -59,6 +59,31 @@ test("a plain package with main/exports and no app framework is still a library" assert.deepEqual(metadata.projectTypes, ["library"]); }); +test("a VS Code extension package is its own project type, not a library", () => { + const files = [ + fileOf("package.json", JSON.stringify({ name: "ws", private: true })), + fileOf("apps/web/package.json", JSON.stringify({ name: "web", dependencies: { next: "^15.0.0" } })), + fileOf("apps/extension/package.json", JSON.stringify({ + name: "ext", + main: "./dist/extension.js", + engines: { vscode: "^1.100.0" }, + contributes: { commands: [] } + })), + fileOf("apps/extension/src/extension.ts", "export function activate() {}") + ]; + const metadata = detectProjectMetadata("/p", "nextjs", files, ["nextjs"]); + + assert.ok(metadata.projectTypes.includes("web-app")); + assert.ok(metadata.projectTypes.includes("vscode-extension")); + assert.ok(!metadata.projectTypes.includes("library")); +}); + +test("an extension manifest with only contributes is recognised without engines", () => { + const files = [fileOf("package.json", JSON.stringify({ name: "ext", main: "out/extension.js", contributes: { commands: [] } }))]; + const metadata = detectProjectMetadata("/p", "unknown", files, []); + assert.deepEqual(metadata.projectTypes, ["vscode-extension"]); +}); + // ------------------------------------------------------------ entry + scope test("barrel index files that the app imports are not entry points", () => { From 605e020bf1a35f105dc357087e85dab19f3cea07 Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Mon, 2 Nov 2026 06:48:47 +0800 Subject: [PATCH 3/6] fix(flow): resolve any feature flow and include high-confidence features in --all generateFeatureFlows compared confidence with strict equality, so the uncapped list dropped high-confidence features. A target missing from the curated flows now falls back to the uncapped list in the CLI and MCP. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRWEEb5zotxkY7QpbA2 --- docs/commands.md | 2 +- .../cli/src/analyzers/pipeline/projectMap.ts | 6 ++-- packages/cli/src/commands/flow.ts | 34 ++++++++++++++----- packages/cli/src/mcp/server.ts | 2 +- packages/cli/test/flow-command.test.ts | 28 ++++++++++++++- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 99b659f..50cbf30 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -360,7 +360,7 @@ It should help answer: ```bash devmap flow # curated top flows from the snapshot devmap flow --all # uncapped: also medium-confidence features and non-API routes -devmap flow authentication # one specific flow (match is case-insensitive) +devmap flow authentication # one specific flow (case-insensitive; falls back to the uncapped list when it isn't curated) devmap flow --json ``` diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 7520312..8d4e2f6 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -928,17 +928,19 @@ function generateMinimalFlows( ]; } +const CONFIDENCE_RANK: Record = { low: 0, medium: 1, high: 2 }; + export function generateFeatureFlows( features: FeatureInfo[], fileIndex: Record, options: { limit?: number; minConfidence?: "high" | "medium" } = {} ): FlowInfo[] { const limit = options.limit ?? 3; - const minConfidence = options.minConfidence ?? "high"; + const minimumRank = CONFIDENCE_RANK[options.minConfidence ?? "high"]; return features .filter((feature) => - feature.confidence === minConfidence + CONFIDENCE_RANK[feature.confidence] >= minimumRank && feature.businessFlow.length > 1 && !feature.businessFlow.some((step) => /^Identify files related to /i.test(step)) ) diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index e53044c..bdd38ae 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -108,7 +108,7 @@ export async function runFlow( const flows = options.all ? buildAllFlows(snapshot) : snapshot.flows; - const resolved = resolveFlowTarget(flows, target); + const resolved = resolveFlowTarget(flows, target, options.all ? undefined : () => buildAllFlows(snapshot)); const shouldWrite = options.write !== false; let client: AiClient | undefined; @@ -208,14 +208,34 @@ function buildAllFlows(snapshot: ProjectMap): FlowInfo[] { function resolveFlowTarget( flows: FlowInfo[], - target: string | undefined + target: string | undefined, + fallback?: () => FlowInfo[] ): FlowInfo[] { if (!target || target.trim() === "") return flows; - const exact = flows.filter(f => f.name.toLowerCase() === target.toLowerCase()); + const direct = matchFlows(flows, target); + if (direct.length > 0) return direct; + + const candidates = fallback ? fallback() : flows; + if (fallback) { + const expanded = matchFlows(candidates, target); + if (expanded.length > 0) return expanded; + } + + const names = candidates.map(f => f.name).join(", ") || "(none detected)"; + throw new DevmapError( + `"${target}" isn't a known flow.`, + `Known flows: ${names}.` + ); +} + +function matchFlows(flows: FlowInfo[], target: string): FlowInfo[] { + const needle = target.toLowerCase(); + + const exact = flows.filter(f => f.name.toLowerCase() === needle); if (exact.length > 0) return exact; - const partial = flows.filter(f => f.name.toLowerCase().includes(target.toLowerCase())); + const partial = flows.filter(f => f.name.toLowerCase().includes(needle)); if (partial.length === 1) return partial; if (partial.length > 1) { throw new DevmapError( @@ -224,11 +244,7 @@ function resolveFlowTarget( ); } - const names = flows.map(f => f.name).join(", ") || "(none detected)"; - throw new DevmapError( - `"${target}" isn't a known flow.`, - `Known flows: ${names}. Run devmap flow --all to see the full list.` - ); + return []; } async function narrateFlow( diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index 025fdef..ac4183e 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -208,7 +208,7 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { title: "Trace a flow", description: "Trace how a feature or API request works end to end (ordered steps with file paths). " - + "Omit target to list the curated top flows. Static analysis only, no AI.", + + "Omit target to list the curated top flows; a target that is not curated is looked up among all flows. Static analysis only, no AI.", inputSchema: { target: z.string().optional().describe("Flow name (case-insensitive, partial match allowed)."), all: z.boolean().optional().describe("Include lower-confidence features and non-API routes.") diff --git a/packages/cli/test/flow-command.test.ts b/packages/cli/test/flow-command.test.ts index b2ce6b0..960528a 100644 --- a/packages/cli/test/flow-command.test.ts +++ b/packages/cli/test/flow-command.test.ts @@ -13,7 +13,7 @@ import type { AiCompletionRequest, AiCompletionResult } from "../src/ai/types.js"; -import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; +import { createProjectMap, generateFeatureFlows } from "../src/analyzers/pipeline/projectMap.js"; import { saveSnapshot } from "../src/cache/snapshot.js"; import { flowCommand } from "../src/commands/flow.js"; import { resolveEffectiveConfig } from "../src/utils/config.js"; @@ -143,6 +143,32 @@ test("flow resolves a feature flow by case-insensitive name", async () } }); +test("flow falls back to uncapped flows when the curated list lacks it", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-flow-test-")); + + try { + const snapshot = await createProjectMap(nextFixture); + const uncapped = generateFeatureFlows(snapshot.features, snapshot.fileIndex, { + limit: Infinity, + minConfidence: "medium" + }); + assert.ok(uncapped.length > 0, "fixture should yield at least one feature flow"); + + snapshot.flows = []; + await saveSnapshot(projectRoot, snapshot); + + const target = uncapped[0].name.replace(/ flow$/i, "").toUpperCase(); + const logs = stripAnsi( + await captureOutput(() => flowCommand(target, { projectRoot }, { loadConfig: async () => null })) + ); + + assert.match(logs, /Wrote \.devmap\/flows\//); + assert.equal(await flowCount(projectRoot), 1); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + test("flow rejects an unknown target with a helpful hint", async () => { const projectRoot = await projectFromFixture(nextFixture); From 3752a17433bea6b5fac808355c546e6e82a19ade Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Mon, 2 Nov 2026 06:51:46 +0800 Subject: [PATCH 4/6] feat(init): make the API key optional and register the MCP server init no longer fails without a provider key: it skips AI setup and still creates the project files. `devmap config key` sets the key later, and `init --mcp` (or the interactive prompt) writes opencode.json, .mcp.json or .cursor/mcp.json by merging into any existing file. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRWEEb5zotxkY7QpbA2 --- README.md | 10 +- docs/commands.md | 36 ++++- packages/cli/src/commands/analyze.ts | 2 +- packages/cli/src/commands/config.ts | 108 ++++++++++++- packages/cli/src/commands/explain.ts | 2 +- packages/cli/src/commands/flow.ts | 2 +- packages/cli/src/commands/init.ts | 143 +++++++++++------ packages/cli/src/index.ts | 30 +++- packages/cli/src/utils/mcpConfig.ts | 137 ++++++++++++++++ packages/cli/test/config-command.test.ts | 79 +++++++++- packages/cli/test/init-and-errors.test.ts | 182 ++++++++++++++++++++-- 11 files changed, 651 insertions(+), 80 deletions(-) create mode 100644 packages/cli/src/utils/mcpConfig.ts diff --git a/README.md b/README.md index 1051a9e..ae9f8d1 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,10 @@ claude mcp add devmap -- npx -y @flaid/devmap mcp } ``` +`devmap init --mcp opencode,claude,cursor` writes this entry for you +(`opencode.json`, `.mcp.json`, `.cursor/mcp.json`) and merges into an existing +file without touching other servers. Interactive `devmap init` offers it too. + The server is read-only and static by default (no AI provider is contacted unless you start it with `--allow-ai`). @@ -298,7 +302,7 @@ Use your preferred AI tool to change it. DevMap is free and open source. -AI features require a provider API key. DevMap uses Groq by default — analysis runs on free-tier infrastructure. +The API key is optional. Static analysis, the generated files and the MCP server work without one; AI-written narration and `devmap explain` need a provider key. DevMap uses Groq by default — analysis runs on free-tier infrastructure. | Provider | Status | | ------------------------------- | ------- | @@ -313,6 +317,10 @@ the selected model. For OpenRouter, pressing Enter at model ID uses that free or paid model instead. Change it later with `devmap config model `. +Press Enter at the key prompt to skip AI and add a key later with +`devmap config key` (or `devmap config key `; it also reads +`GROQ_API_KEY` / `OPENROUTER_API_KEY`). + API keys are stored locally: ```txt diff --git a/docs/commands.md b/docs/commands.md index 50cbf30..8748994 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,6 +15,7 @@ configuration command: * `devmap explain` * `devmap mcp` * `devmap doctor` +* `devmap config key` * `devmap config model` --- @@ -33,13 +34,19 @@ It should be run once per machine/project, or again when changing provider crede ```bash devmap init +devmap init --mcp opencode,claude,cursor # also register the MCP server ``` +The API key is optional. Press Enter at the key prompt (or run without +`GROQ_API_KEY` / `OPENROUTER_API_KEY` in a non-interactive shell) to skip AI +setup: the project files are still created and nothing is written to +`~/.devmap/config.json`. Add a key later with `devmap config key`. + ### Responsibilities * Confirm AI provider -* Input API key or read environment variable -* Validate API key +* Input API key or read environment variable (optional, Enter skips) +* Validate API key when one is given * Ask for a Groq model from the provider model list * Ask for an OpenRouter model; Enter defaults to `openrouter/free` * Save global configuration to `~/.devmap/config.json` @@ -48,6 +55,9 @@ devmap init * Add `.devmap/` to `.gitignore` * Generate `DEVMAP.md` * Handle `AGENTS.md` safely +* Optionally register the MCP server (`--mcp`, or the interactive prompt): + `opencode.json`, `.mcp.json` or `.cursor/mcp.json`. An existing file is merged, + never overwritten, and a file that is not plain JSON is left alone ### Flow @@ -573,6 +583,24 @@ Snapshot is stale --- +## `devmap config key` + +Set or replace the AI provider API key. AI is optional, so this is how a key +gets added after `devmap init` was run without one. + +```bash +devmap config key # reads GROQ_API_KEY / OPENROUTER_API_KEY, else prompts +devmap config key gsk_xxx +devmap config key sk-or-xxx --provider openrouter +``` + +The key is validated against the provider before it is saved. The configured +provider and model are kept; with no config yet it defaults to Groq and +automatic model routing. A custom endpoint needs `devmap init` first because it +needs a base URL. + +--- + ## `devmap config model` Set a global model override for AI-powered commands. @@ -679,8 +707,8 @@ Contract: * runtime failures return a JSON object with `status`, `error`, and optional `hint` * `devmap mcp` is the exception: it has no `--json` flag because stdout is the MCP protocol stream -* `init --json` never prompts and therefore requires `GROQ_API_KEY`, - `OPENROUTER_API_KEY`, or an existing API key +* `init --json` never prompts; without `GROQ_API_KEY`, `OPENROUTER_API_KEY` or an + existing API key it skips AI setup (`aiConfigured: false`) * package-manager wrapper warnings may appear on stderr and are not part of the DevMap JSON document diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 71a4fe2..7ef4870 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -243,7 +243,7 @@ async function printOrGenerateInterpretation( ?? (() => resolveEffectiveConfig(projectRoot)); const config = await loadConfig(); if (!config?.apiKey) { - output.note("AI architecture interpretation is not configured. Run devmap init to enable it."); + output.note("AI architecture interpretation is not configured. Run devmap config key to enable it."); return snapshot; } diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index de49baa..36a9838 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -3,10 +3,13 @@ import { readConfig, writeConfig, writeLocalConfig, - type DevmapConfig + type DevmapConfig, + type DevmapProvider } from "../utils/config.js"; import { PROVIDERS } from "../ai/registry.js"; +import { DevmapError } from "../utils/errors.js"; import { output, withJsonOutput } from "../utils/output.js"; +import { createPrompt, type Prompt } from "../utils/prompt.js"; export type ConfigDependencies = { json?: boolean; @@ -46,11 +49,11 @@ async function updateModel( if (!config) { output.error("DevMap is not configured yet."); - output.note("Run devmap init before changing the model."); + output.note("Run devmap config key before changing the model."); return { status: "error", error: "DevMap is not configured yet.", - hint: "Run devmap init before changing the model." + hint: "Run devmap config key before changing the model." }; } @@ -105,3 +108,102 @@ async function updateModel( : {}) }; } + +export type ConfigKeyDependencies = ConfigDependencies & { + provider?: DevmapProvider; + prompt?: Prompt; + isInteractive?: boolean; + environment?: NodeJS.ProcessEnv; + validateApiKey?: ( + apiKey: string, + provider: DevmapProvider, + baseUrl?: string + ) => Promise; +}; + +export async function configKeyCommand( + apiKey: string | undefined, + dependencies: ConfigKeyDependencies = {} +): Promise { + if (dependencies.json) { + await withJsonOutput(async () => { + output.json(await updateKey(apiKey, dependencies)); + }); + return; + } + + await updateKey(apiKey, dependencies); +} + +async function updateKey( + apiKeyArgument: string | undefined, + dependencies: ConfigKeyDependencies +): Promise> { + const environment = dependencies.environment ?? process.env; + const loadConfig = dependencies.loadConfig ?? readConfig; + const persistConfig = dependencies.persistConfig ?? writeConfig; + const existing = await loadConfig(); + + const provider: DevmapProvider = dependencies.provider + ?? existing?.provider + ?? (environment.OPENROUTER_API_KEY && !environment.GROQ_API_KEY ? "openrouter" : "groq"); + const descriptor = PROVIDERS[provider]; + const base = existing?.provider === provider ? existing : undefined; + + if (descriptor.requiresBaseUrl && !base?.baseUrl) { + throw new DevmapError( + `${descriptor.displayName} needs a base URL before it can take a key.`, + "Run devmap init in an interactive terminal to set the endpoint." + ); + } + + let apiKey = apiKeyArgument?.trim() || environment[descriptor.envVarName]?.trim() || ""; + if (!apiKey) { + const interactive = dependencies.prompt + ? true + : dependencies.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); + + if (!interactive) { + throw new DevmapError( + `No ${descriptor.displayName} API key was given.`, + `Pass it as an argument or set ${descriptor.envVarName}.` + ); + } + + const prompt = dependencies.prompt ?? createPrompt(); + try { + apiKey = (await prompt.ask(`${descriptor.displayName} API key: `)).trim(); + } finally { + if (!dependencies.prompt) prompt.close(); + } + + if (!apiKey) { + throw new DevmapError( + `No ${descriptor.displayName} API key was given.`, + descriptor.apiKeyHintUrl + ? `Create one at ${descriptor.apiKeyHintUrl}.` + : `Set ${descriptor.envVarName} or pass the key as an argument.` + ); + } + } + + const validate = dependencies.validateApiKey + ?? ((key: string, selected: DevmapProvider, baseUrl?: string) => + PROVIDERS[selected].inspect(key, undefined, baseUrl).then(() => undefined)); + + output.step(`Validating ${descriptor.displayName} API key`); + await validate(apiKey, provider, base?.baseUrl); + output.success(`${descriptor.displayName} API key is valid`); + + await persistConfig({ + ...(base ?? { provider, model: "auto" }), + apiKey + }); + output.success("API key saved to ~/.devmap/config.json"); + + return { + status: "ok", + provider, + model: (base ?? { model: "auto" }).model + }; +} diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 34d643f..081f830 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -87,7 +87,7 @@ export async function runExplain( if (!config?.apiKey) { throw new DevmapError( "devmap explain requires an AI provider, but none is configured.", - "Run devmap init to set up Groq or OpenRouter, then try again." + "Run devmap config key to set up Groq or OpenRouter, then try again." ); } diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index bdd38ae..6f7e069 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -120,7 +120,7 @@ export async function runFlow( const config = await loadConfig(); if (!config?.apiKey) { - output.note("AI flow narration is not configured. Run devmap init to enable it."); + output.note("AI flow narration is not configured. Run devmap config key to enable it."); } else { const createAiClient = dependencies.createAiClient ?? createDefaultAiClient; client = createAiClient(config); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index bd02792..911b7bd 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -15,6 +15,13 @@ import { import { ensureDevmapFile } from "../utils/devmapFile.js"; import { DevmapError } from "../utils/errors.js"; import { ensureDevmapIgnored } from "../utils/gitignore.js"; +import { + MCP_CLIENT_LABELS, + MCP_CLIENTS, + ensureMcpConfig, + type McpClient, + type McpConfigResult +} from "../utils/mcpConfig.js"; import { output, withJsonOutput } from "../utils/output.js"; import { createPrompt, type Prompt } from "../utils/prompt.js"; import { renderWelcomeBrandPanel } from "../utils/welcome.js"; @@ -34,6 +41,7 @@ export type InitDependencies = { environmentOpenRouterApiKey?: string; loadConfig?: () => Promise; persistConfig?: (config: DevmapConfig) => Promise; + mcpClients?: McpClient[]; }; export async function initCommand(dependencies: InitDependencies = {}): Promise { @@ -104,19 +112,27 @@ async function runInit( provider }); - output.step(`Validating ${providerName} API key`); - await validateApiKey(apiKey, provider, baseUrl); - output.success(`${providerName} API key is valid`); - - const model = await resolveInitialModel({ - provider, - baseUrl, - apiKey, - prompt, - interactive, - existingConfig, - listModels: dependencies.listModels - }); + let model: string | undefined; + if (apiKey) { + output.step(`Validating ${providerName} API key`); + await validateApiKey(apiKey, provider, baseUrl); + output.success(`${providerName} API key is valid`); + + model = await resolveInitialModel({ + provider, + baseUrl, + apiKey, + prompt, + interactive, + existingConfig, + listModels: dependencies.listModels + }); + } else { + output.note("No API key set. DevMap works without AI; add one later with: devmap config key"); + if (descriptor.apiKeyHintUrl) { + output.note(`Create a ${providerName} key at ${descriptor.apiKeyHintUrl}`); + } + } const agentsStatus = await inspectAgentsFile(projectRoot); const appendToExistingAgents = agentsStatus === "existing" @@ -127,37 +143,54 @@ async function runInit( )) : false; + const mcpClients = await resolveMcpClients(prompt, interactive, dependencies.mcpClients); + await mkdir(resolve(projectRoot, ".devmap"), { recursive: true }); - await persistConfig({ - provider, - apiKey, - model, - ...(baseUrl ? { baseUrl } : {}) - }); + if (apiKey && model) { + await persistConfig({ + provider, + apiKey, + model, + ...(baseUrl ? { baseUrl } : {}) + }); + } const ignored = await ensureDevmapIgnored(projectRoot); const devmapFileCreated = await ensureDevmapFile(projectRoot); const agentsResult = await ensureAgentsFile(projectRoot, appendToExistingAgents); + const mcpResults: McpConfigResult[] = []; + for (const client of mcpClients) { + mcpResults.push(await ensureMcpConfig(projectRoot, client)); + } - output.success("Config saved to ~/.devmap/config.json"); - if (model !== "auto") { - output.note(`${providerName} model: ${model}`); - output.note("Change it later with: devmap config model "); + if (apiKey && model) { + output.success("Config saved to ~/.devmap/config.json"); + if (model !== "auto") { + output.note(`${providerName} model: ${model}`); + output.note("Change it later with: devmap config model "); + } } output.success(ignored ? "Added .devmap/ to .gitignore" : ".devmap/ already ignored"); output.success(devmapFileCreated ? "Created DEVMAP.md" : "DEVMAP.md already exists"); printAgentsResult(agentsResult); + for (const result of mcpResults) { + printMcpResult(result); + } + if (mcpResults.length === 0) { + output.note("Register the MCP server for your agent with: devmap init --mcp opencode|claude|cursor"); + } output.step("Next: devmap analyze"); return { status: "ok", - provider, - model, + aiConfigured: Boolean(apiKey && model), + ...(apiKey && model ? { provider, model } : {}), files: { gitignoreUpdated: ignored, devmapFileCreated, agentsFile: agentsResult }, + mcp: mcpResults, next: "devmap analyze" }; } finally { @@ -169,6 +202,38 @@ function isAffirmative(answer: string): boolean { return ["y", "yes"].includes(answer.trim().toLowerCase()); } +function printMcpResult(result: McpConfigResult): void { + const label = MCP_CLIENT_LABELS[result.client]; + if (result.status === "created") { + output.success(`Created ${result.file} with the DevMap MCP server`); + } else if (result.status === "added") { + output.success(`Added the DevMap MCP server to ${result.file}`); + } else if (result.status === "unchanged") { + output.success(`${result.file} already registers the DevMap MCP server`); + } else { + output.warning(`Skipped ${label}: ${result.reason ?? "could not update the file"}.`); + } +} + +async function resolveMcpClients( + prompt: Prompt | null, + interactive: boolean, + requested: McpClient[] | undefined +): Promise { + if (requested) return requested; + if (!interactive || !prompt) return []; + + const choice = await prompt.select( + "Register the DevMap MCP server for an agent?", + [ + { label: "Skip", value: "skip" }, + ...MCP_CLIENTS.map((client) => ({ label: MCP_CLIENT_LABELS[client], value: client })) + ], + "skip" + ); + return choice === "skip" ? [] : [choice]; +} + function printAgentsResult(result: AgentsFileResult): void { if (result === "created") { output.success("Created AGENTS.md"); @@ -196,41 +261,23 @@ type ResolveApiKeyOptions = { provider: DevmapProvider; }; -async function resolveApiKey(options: ResolveApiKeyOptions): Promise { +async function resolveApiKey(options: ResolveApiKeyOptions): Promise { if (options.environmentApiKey?.trim()) { return options.environmentApiKey.trim(); } - const descriptor = PROVIDERS[options.provider]; - const providerName = descriptor.displayName; + const providerName = PROVIDERS[options.provider].displayName; if (!options.interactive || !options.prompt) { - if (options.existingApiKey?.trim()) { - return options.existingApiKey.trim(); - } - - throw new DevmapError( - `An ${providerName} API key is required to initialize DevMap.`, - `Run devmap init in an interactive terminal or set ${descriptor.envVarName}.` - ); + return options.existingApiKey?.trim() || null; } const keyPrompt = options.existingApiKey ? `${providerName} API key [press Enter to keep existing]: ` - : `${providerName} API key: `; + : `${providerName} API key [press Enter to skip]: `; const answer = (await options.prompt.ask(keyPrompt)).trim(); - const apiKey = answer || options.existingApiKey?.trim(); - - if (!apiKey) { - throw new DevmapError( - `An ${providerName} API key is required.`, - descriptor.apiKeyHintUrl - ? `Create one at ${descriptor.apiKeyHintUrl}.` - : `Set ${descriptor.envVarName} or create a key in your endpoint's console.` - ); - } - return apiKey; + return answer || options.existingApiKey?.trim() || null; } type ResolveBaseUrlOptions = { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7481d4a..a8fb400 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; import { analyzeCommand } from "./commands/analyze.js"; -import { configModelCommand } from "./commands/config.js"; +import { configKeyCommand, configModelCommand } from "./commands/config.js"; import { doctorCommand } from "./commands/doctor.js"; import { explainCommand } from "./commands/explain.js"; import { flowCommand } from "./commands/flow.js"; @@ -10,8 +10,9 @@ import { mapCommand } from "./commands/map.js"; import { mcpCommand } from "./commands/mcp.js"; import { onboardingCommand } from "./commands/onboarding.js"; import { printHelp } from "./utils/help.js"; +import { parseMcpClients } from "./utils/mcpConfig.js"; import { printWelcome } from "./utils/welcome.js"; -import { runSafely } from "./utils/errors.js"; +import { DevmapError, runSafely } from "./utils/errors.js"; import { DEVMAP_VERSION } from "./utils/packageMetadata.js"; const program = new Command(); @@ -24,8 +25,12 @@ program program .command("init") .description("Initialize DevMap configuration") + .option("--mcp ", "also register the MCP server (comma-separated: opencode, claude, cursor)") .option("--json", "output machine-readable JSON") - .action((options) => initCommand({ json: options.json })); + .action(async (options) => initCommand({ + json: options.json, + ...(options.mcp ? { mcpClients: parseMcpClients(options.mcp) } : {}) + })); program .command("analyze") @@ -97,6 +102,25 @@ configCommand local: options.local })); +configCommand + .command("key") + .description("Set or replace the AI provider API key (AI is optional)") + .argument("[apiKey]", "API key; omit to read it from the provider env var or be prompted") + .option("--provider ", "groq, openrouter or custom (defaults to the configured provider)") + .option("--json", "output machine-readable JSON") + .action(async (apiKey, options) => { + if (options.provider && !["groq", "openrouter", "custom"].includes(options.provider)) { + throw new DevmapError( + `Unknown provider: ${options.provider}.`, + "Use groq, openrouter or custom." + ); + } + await configKeyCommand(apiKey, { + json: options.json, + ...(options.provider ? { provider: options.provider } : {}) + }); + }); + program .command("mcp") .description("Run DevMap as an MCP server (stdio) so AI agents can query the project map") diff --git a/packages/cli/src/utils/mcpConfig.ts b/packages/cli/src/utils/mcpConfig.ts new file mode 100644 index 0000000..057ae8c --- /dev/null +++ b/packages/cli/src/utils/mcpConfig.ts @@ -0,0 +1,137 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { parseJsonText } from "../analyzers/analysis/jsonText.js"; +import { DevmapError } from "./errors.js"; + +export type McpClient = "opencode" | "claude" | "cursor"; +export type McpConfigStatus = "created" | "added" | "unchanged" | "skipped"; + +export type McpConfigResult = { + client: McpClient; + file: string; + status: McpConfigStatus; + reason?: string; +}; + +export const MCP_CLIENTS: readonly McpClient[] = ["opencode", "claude", "cursor"]; + +export const MCP_CLIENT_LABELS: Record = { + opencode: "OpenCode (opencode.json)", + claude: "Claude Code (.mcp.json)", + cursor: "Cursor (.cursor/mcp.json)" +}; + +type McpTarget = { + file: string; + containerKey: string; + entry: Record; + header: Record; +}; + +const SERVER_NAME = "devmap"; + +const MCP_TARGETS: Record = { + opencode: { + file: "opencode.json", + containerKey: "mcp", + header: { $schema: "https://opencode.ai/config.json" }, + entry: { + type: "local", + command: ["npx", "-y", "@flaid/devmap", "mcp"], + enabled: true, + timeout: 30000 + } + }, + claude: { + file: ".mcp.json", + containerKey: "mcpServers", + header: {}, + entry: { command: "npx", args: ["-y", "@flaid/devmap", "mcp"] } + }, + cursor: { + file: ".cursor/mcp.json", + containerKey: "mcpServers", + header: {}, + entry: { + command: "npx", + args: ["-y", "@flaid/devmap", "mcp", "--root", "${workspaceFolder}"] + } + } +}; + +export function parseMcpClients(value: string): McpClient[] { + const requested = value + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0); + + const unknown = requested.filter((item) => !MCP_CLIENTS.includes(item as McpClient)); + if (unknown.length > 0 || requested.length === 0) { + throw new DevmapError( + `Unknown MCP client: ${unknown.join(", ") || "(none given)"}.`, + `Use one or more of: ${MCP_CLIENTS.join(", ")}.` + ); + } + + return [...new Set(requested)] as McpClient[]; +} + +export async function ensureMcpConfig( + projectRoot: string, + client: McpClient +): Promise { + const target = MCP_TARGETS[client]; + const path = join(projectRoot, target.file); + const base = { client, file: target.file }; + + let raw: string | null = null; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + if (raw === null) { + await writeJson(path, { + ...target.header, + [target.containerKey]: { [SERVER_NAME]: target.entry } + }); + return { ...base, status: "created" }; + } + + let parsed: unknown; + try { + parsed = parseJsonText(raw); + } catch { + return { ...base, status: "skipped", reason: "the file is not plain JSON, add the entry by hand" }; + } + + if (!isRecord(parsed)) { + return { ...base, status: "skipped", reason: "the file does not contain a JSON object" }; + } + + const existing = parsed[target.containerKey]; + if (existing !== undefined && !isRecord(existing)) { + return { ...base, status: "skipped", reason: `"${target.containerKey}" is not an object` }; + } + + const container = existing ?? {}; + if (SERVER_NAME in container) { + return { ...base, status: "unchanged" }; + } + + await writeJson(path, { + ...parsed, + [target.containerKey]: { ...container, [SERVER_NAME]: target.entry } + }); + return { ...base, status: "added" }; +} + +async function writeJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/test/config-command.test.ts b/packages/cli/test/config-command.test.ts index a131239..9737f40 100644 --- a/packages/cli/test/config-command.test.ts +++ b/packages/cli/test/config-command.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { configModelCommand } from "../src/commands/config.js"; +import { configKeyCommand, configModelCommand } from "../src/commands/config.js"; +import { DevmapError } from "../src/utils/errors.js"; import type { DevmapConfig } from "../src/utils/config.js"; function persist(ref: { value: T | null }) { @@ -57,7 +58,7 @@ test("config model requires an existing initialized config", async () => { )); assert.equal(persisted, false); - assert.match(logs, /Run devmap init/i); + assert.match(logs, /Run devmap config key/i); }); test("config model auto explains OpenRouter free routing", async () => { @@ -100,3 +101,77 @@ async function captureOutput(action: () => Promise): Promise { console.error = originalError; } } + +test("config key creates a keyed config on first use and validates before saving", async () => { + const saved: { value: DevmapConfig | null } = { value: null }; + let validatedWith = ""; + + await configKeyCommand("gsk_new", { + environment: {}, + loadConfig: async () => null, + persistConfig: persist(saved), + validateApiKey: async (key, provider) => { + validatedWith = `${provider}:${key}`; + } + }); + + assert.equal(validatedWith, "groq:gsk_new"); + assert.deepEqual(saved.value, { provider: "groq", model: "auto", apiKey: "gsk_new" }); +}); + +test("config key replaces the key and keeps provider and model", async () => { + const saved: { value: DevmapConfig | null } = { value: null }; + + await configKeyCommand("sk-or-new", { + environment: {}, + loadConfig: async () => ({ provider: "openrouter", apiKey: "sk-or-old", model: "openrouter/free" }), + persistConfig: persist(saved), + validateApiKey: async () => undefined + }); + + assert.deepEqual(saved.value, { provider: "openrouter", apiKey: "sk-or-new", model: "openrouter/free" }); +}); + +test("config key reads the key from the provider environment variable", async () => { + const saved: { value: DevmapConfig | null } = { value: null }; + + await configKeyCommand(undefined, { + environment: { GROQ_API_KEY: "gsk_env" }, + loadConfig: async () => null, + persistConfig: persist(saved), + validateApiKey: async () => undefined + }); + + assert.equal(saved.value?.apiKey, "gsk_env"); +}); + +test("config key does not save a key that fails validation", async () => { + let persisted = false; + + await assert.rejects( + configKeyCommand("bad", { + environment: {}, + loadConfig: async () => null, + persistConfig: async () => { + persisted = true; + }, + validateApiKey: async () => { + throw new DevmapError("Invalid key."); + } + }), + DevmapError + ); + assert.equal(persisted, false); +}); + +test("config key fails clearly without a key when there is no terminal", async () => { + await assert.rejects( + configKeyCommand(undefined, { + environment: {}, + isInteractive: false, + loadConfig: async () => null, + persistConfig: async () => undefined + }), + (error: unknown) => error instanceof DevmapError && /GROQ_API_KEY/.test(error.hint ?? "") + ); +}); diff --git a/packages/cli/test/init-and-errors.test.ts b/packages/cli/test/init-and-errors.test.ts index b8e7f0f..ff3f958 100644 --- a/packages/cli/test/init-and-errors.test.ts +++ b/packages/cli/test/init-and-errors.test.ts @@ -16,6 +16,7 @@ import { } from "../src/utils/config.js"; import { buildDevmapFile, ensureDevmapFile } from "../src/utils/devmapFile.js"; import { DevmapError, handleError } from "../src/utils/errors.js"; +import { ensureMcpConfig, parseMcpClients } from "../src/utils/mcpConfig.js"; import type { Prompt } from "../src/utils/prompt.js"; test("DEVMAP.md contains workflow and AI-agent guidance", () => { @@ -285,7 +286,7 @@ test("interactive init selects Groq with the provider menu", async () => { apiKey: "gsk_fixture", model: "llama-3.3-70b-versatile" }); - assert.deepEqual(prompt.selections, ["AI provider", "Groq model"]); + assert.deepEqual(prompt.selections, ["AI provider", "Groq model", "Register the DevMap MCP server for an agent?"]); } finally { await rm(projectRoot, { recursive: true, force: true }); } @@ -320,7 +321,7 @@ test("interactive init stores the OpenRouter model selected by the user", async apiKey: "sk-or-fixture", model: "anthropic/claude-3.5-haiku" }); - assert.deepEqual(prompt.selections, ["AI provider"]); + assert.deepEqual(prompt.selections, ["AI provider", "Register the DevMap MCP server for an agent?"]); assert.ok(prompt.questions.some((question) => /OpenRouter model/i.test(question))); } finally { await rm(projectRoot, { recursive: true, force: true }); @@ -354,27 +355,176 @@ test("interactive OpenRouter init defaults to the free router on Enter", async ( } }); -test("non-interactive init explains how to provide an API key", async () => { - const projectRoot = await mkdtemp(join(tmpdir(), "devmap-init-key-test-")); +test("non-interactive init without an API key still sets up the project and skips AI", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-init-nokey-test-")); + let persisted = false; + let validated = false; try { - await assert.rejects( - initCommand({ - projectRoot, - isInteractive: false, - loadConfig: async () => null, - persistConfig: async () => undefined, - validateApiKey: async () => undefined - }), - (error: unknown) => error instanceof DevmapError - && /API key is required/.test(error.message) - && error.hint?.includes("GROQ_API_KEY") === true - ); + await initCommand({ + projectRoot, + isInteractive: false, + environmentApiKey: "", + loadConfig: async () => null, + persistConfig: async () => { + persisted = true; + }, + validateApiKey: async () => { + validated = true; + } + }); + + assert.equal(persisted, false); + assert.equal(validated, false); + assert.match(await readFile(join(projectRoot, ".gitignore"), "utf8"), /\.devmap\//); + assert.match(await readFile(join(projectRoot, "DEVMAP.md"), "utf8"), /Navigation Order/); + assert.match(await readFile(join(projectRoot, "AGENTS.md"), "utf8"), /DevMap Context/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("interactive init lets the user skip the API key with Enter", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-init-skip-test-")); + const prompt = createFakePrompt([""]); + let persisted = false; + + try { + await initCommand({ + projectRoot, + prompt, + isInteractive: true, + loadConfig: async () => null, + persistConfig: async () => { + persisted = true; + }, + validateApiKey: async () => { + throw new Error("must not validate an empty key"); + } + }); + + assert.equal(persisted, false); + assert.deepEqual(prompt.selections, ["AI provider", "Register the DevMap MCP server for an agent?"]); + await access(join(projectRoot, "DEVMAP.md")); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("init keeps an existing key when run again without one in the environment", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-init-existing-test-")); + let validatedKey = ""; + + try { + await initCommand({ + projectRoot, + isInteractive: false, + environmentApiKey: "", + loadConfig: async () => ({ provider: "groq", apiKey: "gsk_saved", model: "auto" }), + persistConfig: async () => undefined, + validateApiKey: async (apiKey) => { + validatedKey = apiKey; + } + }); + + assert.equal(validatedKey, "gsk_saved"); } finally { await rm(projectRoot, { recursive: true, force: true }); } }); +test("init --mcp registers the MCP server for the requested clients", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-init-mcp-test-")); + + try { + await initCommand({ + projectRoot, + isInteractive: false, + environmentApiKey: "", + loadConfig: async () => null, + persistConfig: async () => undefined, + mcpClients: ["opencode", "claude", "cursor"] + }); + + const opencode = JSON.parse(await readFile(join(projectRoot, "opencode.json"), "utf8")); + assert.equal(opencode.mcp.devmap.type, "local"); + assert.deepEqual(opencode.mcp.devmap.command, ["npx", "-y", "@flaid/devmap", "mcp"]); + + const claude = JSON.parse(await readFile(join(projectRoot, ".mcp.json"), "utf8")); + assert.deepEqual(claude.mcpServers.devmap, { command: "npx", args: ["-y", "@flaid/devmap", "mcp"] }); + + const cursor = JSON.parse(await readFile(join(projectRoot, ".cursor", "mcp.json"), "utf8")); + assert.ok(cursor.mcpServers.devmap.args.includes("${workspaceFolder}")); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("interactive init offers MCP registration and honours the choice", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-init-mcp-choice-test-")); + + try { + await initCommand({ + projectRoot, + prompt: createFakePrompt([""], ["groq", "opencode"]), + isInteractive: true, + loadConfig: async () => null, + persistConfig: async () => undefined + }); + + await access(join(projectRoot, "opencode.json")); + await assert.rejects(access(join(projectRoot, ".mcp.json"))); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("MCP config merges into an existing file without touching other entries", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-merge-test-")); + + try { + await writeFile(join(projectRoot, "opencode.json"), JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "dark", + mcp: { other: { type: "local", command: ["other"] } } + }), "utf8"); + + const first = await ensureMcpConfig(projectRoot, "opencode"); + assert.equal(first.status, "added"); + + const merged = JSON.parse(await readFile(join(projectRoot, "opencode.json"), "utf8")); + assert.equal(merged.theme, "dark"); + assert.deepEqual(merged.mcp.other, { type: "local", command: ["other"] }); + assert.ok(merged.mcp.devmap); + + const second = await ensureMcpConfig(projectRoot, "opencode"); + assert.equal(second.status, "unchanged"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("MCP config never overwrites a file it cannot parse", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-jsonc-test-")); + const original = '{\n // keep my comment\n "mcp": {}\n}\n'; + + try { + await writeFile(join(projectRoot, "opencode.json"), original, "utf8"); + + const result = await ensureMcpConfig(projectRoot, "opencode"); + assert.equal(result.status, "skipped"); + assert.equal(await readFile(join(projectRoot, "opencode.json"), "utf8"), original); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("parseMcpClients validates and de-duplicates the client list", () => { + assert.deepEqual(parseMcpClients("opencode, claude,opencode"), ["opencode", "claude"]); + assert.throws(() => parseMcpClients("vim"), DevmapError); + assert.throws(() => parseMcpClients(""), DevmapError); +}); + test("global error handler returns an exit code without exposing stack output", () => { const logs: string[] = []; const originalError = console.error; From e19796f438a75f4b47b3673805cea8a9a50306a8 Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Mon, 2 Nov 2026 06:53:21 +0800 Subject: [PATCH 5/6] feat(doctor): check the MCP server and snapshot age, treat the AI key as optional doctor starts the MCP server in-process and lists its tools, reports which clients register devmap, and flags a stale snapshot as a note. A missing API key is a note instead of an issue. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRWEEb5zotxkY7QpbA2 --- docs/commands.md | 37 ++++++++---- packages/cli/src/commands/doctor.ts | 58 ++++++++++++++++-- packages/cli/src/mcp/health.ts | 60 +++++++++++++++++++ packages/cli/src/utils/mcpConfig.ts | 19 ++++++ packages/cli/test/doctor.test.ts | 93 ++++++++++++++++++++++++++++- 5 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/mcp/health.ts diff --git a/docs/commands.md b/docs/commands.md index 8748994..0770d6f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -521,11 +521,19 @@ devmap doctor * API key status * Selected model, annotated with its source: `(project override)` when a `.devmap/config.local.json` model is active, `(global)` otherwise -* Snapshot status +* Snapshot status and age (`up to date` or `stale`) +* MCP server self-check: the server is started in-process and its tools are listed + (nothing is written, stdio is not touched) +* MCP clients: which of `opencode.json`, `.mcp.json` and `.cursor/mcp.json` + already register `devmap` * Project detection * OS/platform * Permission issues +A missing API key is not an issue because AI is optional; it is reported as a +note with the command that fixes it (`devmap config key`). A failing MCP +self-check is an issue. + ### Output — No Issues ```txt @@ -541,6 +549,9 @@ Package Manager npm Provider groq Config exists Snapshot valid +Snapshot age up to date +MCP server ready (7 tools) +MCP clients opencode API key valid Model openai/gpt-oss-20b (project override) @@ -559,19 +570,23 @@ Project my-app Framework next.js Workspace Frameworks express Package Manager npm -Provider not configured -Config missing -Snapshot stale -API key missing -Model not configured +Provider groq +Config exists +Snapshot valid +Snapshot age stale +MCP server failed +MCP clients none registered +API key invalid or unreachable +Model openai/gpt-oss-20b (global) + +Notes -Issues found: +The code changed since the last analysis. Run devmap analyze, or let your agent call devmap_analyze. +No agent is registered for the MCP server. Run: devmap init --mcp opencode|claude|cursor -Provider is not configured - Run devmap init to set up an AI provider. +Issues -Snapshot is stale - Run devmap analyze to refresh the snapshot. +The MCP server failed its self-check: . ``` ### Rules diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 067ba0a..cc27b57 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -10,8 +10,9 @@ import { detectFramework, detectFrameworks } from "../analyzers/detectors/index.js"; -import { detectProjectMetadata } from "../analyzers/pipeline/index.js"; +import { createProjectFingerprint, detectProjectMetadata } from "../analyzers/pipeline/index.js"; import { inspectSnapshot } from "../cache/snapshot.js"; +import type { McpHealth } from "../mcp/health.js"; import { readConfig, readLocalConfig, @@ -19,6 +20,7 @@ import { type LocalDevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; +import { detectMcpRegistrations } from "../utils/mcpConfig.js"; import { output, withJsonOutput } from "../utils/output.js"; import { DEVMAP_VERSION } from "../utils/packageMetadata.js"; @@ -33,6 +35,7 @@ export type DoctorDependencies = { config: DevmapConfig, model: string ) => Promise; + checkMcp?: (projectRoot: string) => Promise; }; export async function doctorCommand( @@ -75,6 +78,7 @@ async function runDoctor( ? resolveAiRouting(effectiveConfig, "analyze").model : undefined; const issues: string[] = []; + const notes: string[] = []; const nodeSupported = readNodeMajor(process.version) >= MINIMUM_NODE_MAJOR; let apiKeyStatus = "not configured"; let modelStatus = selectedModel ?? "not configured"; @@ -91,12 +95,45 @@ async function runDoctor( output.keyValue("Config", config ? "exists" : "missing"); output.keyValue("Snapshot", snapshotResult.status); + const snapshotStale = snapshotResult.status === "valid" + ? createProjectFingerprint(files) !== snapshotResult.snapshot.fingerprint + : null; + if (snapshotStale !== null) { + output.keyValue("Snapshot age", snapshotStale ? "stale" : "up to date"); + } + if (snapshotResult.status === "missing") { + notes.push("No snapshot yet. Run devmap analyze, or let your agent call devmap_analyze."); + } else if (snapshotStale) { + notes.push("The code changed since the last analysis. Run devmap analyze, or let your agent call devmap_analyze."); + } + + const checkMcp = dependencies.checkMcp + ?? (async (root: string) => (await import("../mcp/health.js")).checkMcpServer(root)); + const [mcpHealth, mcpClients] = await Promise.all([ + checkMcp(projectRoot), + detectMcpRegistrations(projectRoot) + ]); + output.keyValue( + "MCP server", + mcpHealth.ok ? `ready (${mcpHealth.tools} tools)` : "failed" + ); + output.keyValue( + "MCP clients", + mcpClients.length > 0 ? mcpClients.join(", ") : "none registered" + ); + if (!mcpHealth.ok) { + issues.push(`The MCP server failed its self-check: ${mcpHealth.error ?? "unknown error"}.`); + } + if (mcpClients.length === 0) { + notes.push("No agent is registered for the MCP server. Run: devmap init --mcp opencode|claude|cursor"); + } + if (!config) { - issues.push("Run devmap init to create ~/.devmap/config.json."); + notes.push("AI is optional and not configured. Add a provider key with: devmap config key"); output.keyValue("API key", "not configured"); output.keyValue("Model", "not configured"); } else if (!config.apiKey || !selectedModel) { - issues.push("Run devmap init again to configure the selected provider."); + notes.push("AI is optional and no API key is set. Add one with: devmap config key"); output.keyValue("API key", "missing"); modelStatus = selectedModel ? describeModel(selectedModel, modelSource) @@ -114,7 +151,7 @@ async function runDoctor( output.keyValue("Model", modelStatus); if (!provider.modelAvailable) { - issues.push("Run devmap init or choose an available provider model."); + issues.push("Run devmap config model to choose an available provider model."); } } catch (error) { const message = error instanceof DevmapError @@ -151,6 +188,13 @@ async function runDoctor( } } + if (notes.length > 0) { + output.section("Notes"); + for (const note of notes) { + output.note(note); + } + } + if (issues.length === 0) { output.success("No issues found"); } else { @@ -177,6 +221,12 @@ async function runDoctor( snapshot: snapshotResult.status, apiKey: apiKeyStatus, model: modelStatus, + snapshotStale, + mcp: { + server: mcpHealth, + registeredClients: mcpClients + }, + notes, issues }; } diff --git a/packages/cli/src/mcp/health.ts b/packages/cli/src/mcp/health.ts new file mode 100644 index 0000000..af25803 --- /dev/null +++ b/packages/cli/src/mcp/health.ts @@ -0,0 +1,60 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { DEVMAP_VERSION } from "../utils/packageMetadata.js"; +import { createDevmapServer } from "./server.js"; + +export type McpHealth = { + ok: boolean; + tools: number; + error?: string; +}; + +const HEALTH_TIMEOUT_MS = 5_000; + +const EXPECTED_TOOLS = [ + "devmap_overview", + "devmap_map", + "devmap_flow", + "devmap_routes", + "devmap_impact", + "devmap_find", + "devmap_analyze" +]; + +/** + * Starts the MCP server in-process, connects a client over a linked in-memory + * pair and lists the tools. Nothing is written and no stdio is touched. + */ +export async function checkMcpServer(projectRoot: string): Promise { + const { server } = createDevmapServer({ projectRoot }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "devmap-doctor", version: DEVMAP_VERSION }); + let timer: NodeJS.Timeout | undefined; + + try { + const handshake = (async () => { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return client.listTools(); + })(); + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`no response within ${HEALTH_TIMEOUT_MS / 1000}s`)), + HEALTH_TIMEOUT_MS + ); + }); + + const { tools } = await Promise.race([handshake, timeout]); + const names = new Set(tools.map((tool) => tool.name)); + const missing = EXPECTED_TOOLS.filter((name) => !names.has(name)); + + return missing.length > 0 + ? { ok: false, tools: tools.length, error: `missing tools: ${missing.join(", ")}` } + : { ok: true, tools: tools.length }; + } catch (error) { + return { ok: false, tools: 0, error: error instanceof Error ? error.message : String(error) }; + } finally { + if (timer) clearTimeout(timer); + await client.close().catch(() => undefined); + await server.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/utils/mcpConfig.ts b/packages/cli/src/utils/mcpConfig.ts index 057ae8c..aec2d20 100644 --- a/packages/cli/src/utils/mcpConfig.ts +++ b/packages/cli/src/utils/mcpConfig.ts @@ -135,3 +135,22 @@ async function writeJson(path: string, value: unknown): Promise { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +export async function detectMcpRegistrations(projectRoot: string): Promise { + const registered: McpClient[] = []; + + for (const client of MCP_CLIENTS) { + const target = MCP_TARGETS[client]; + try { + const parsed = parseJsonText(await readFile(join(projectRoot, target.file), "utf8")); + const container = isRecord(parsed) ? parsed[target.containerKey] : undefined; + if (isRecord(container) && SERVER_NAME in container) { + registered.push(client); + } + } catch { + continue; + } + } + + return registered; +} diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index 8da1ef7..2ff789c 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -7,6 +7,7 @@ import test from "node:test"; import { saveSnapshot } from "../src/cache/snapshot.js"; import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; import { doctorCommand } from "../src/commands/doctor.js"; +import { checkMcpServer } from "../src/mcp/health.js"; import { getLocalConfigPath } from "../src/utils/config.js"; test("doctor reports project, provider, model, and snapshot diagnostics", async () => { @@ -75,7 +76,7 @@ test("doctor reports unavailable models without exposing provider errors", async })); assert.match(logs, /Model\s+unavailable: retired-model/); - assert.match(logs, /Run devmap init/i); + assert.match(logs, /devmap config model/i); assert.doesNotMatch(logs, /gsk_fixture/); } finally { await rm(projectRoot, { recursive: true, force: true }); @@ -99,7 +100,9 @@ test("doctor skips network diagnostics when config is missing", async () => { assert.equal(providerCalled, false); assert.match(logs, /Provider\s+not configured/); assert.match(logs, /Snapshot\s+missing/); - assert.match(logs, /Run devmap init/); + assert.match(logs, /AI is optional and not configured/); + assert.match(logs, /devmap config key/); + assert.match(logs, /No issues found/); } finally { await rm(projectRoot, { recursive: true, force: true }); } @@ -272,3 +275,89 @@ function stripAnsi(value: string): string { function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + +test("doctor does not treat a missing API key as an issue and reports it as JSON", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-doctor-nokey-test-")); + + try { + const logs = await captureOutput(() => doctorCommand({ + projectRoot, + json: true, + loadConfig: async () => ({ provider: "groq", model: "auto" }), + checkMcp: async () => ({ ok: true, tools: 7 }) + })); + + const report = JSON.parse(logs) as { status: string; apiKey: string; issues: string[]; notes: string[] }; + assert.equal(report.status, "ok"); + assert.equal(report.apiKey, "missing"); + assert.deepEqual(report.issues, []); + assert.ok(report.notes.some((note) => /devmap config key/.test(note))); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("doctor reports the MCP server, registered clients and snapshot age", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-doctor-mcp-test-")); + + try { + await writeFile(join(projectRoot, "package.json"), JSON.stringify({ name: "mcp-fixture" }), "utf8"); + await writeFile(join(projectRoot, "server.ts"), "export const app = true;\n", "utf8"); + await writeFile( + join(projectRoot, ".mcp.json"), + JSON.stringify({ mcpServers: { devmap: { command: "npx" } } }), + "utf8" + ); + await saveSnapshot(projectRoot, await createProjectMap(projectRoot)); + + const fresh = await captureOutput(() => doctorCommand({ + projectRoot, + loadConfig: async () => null, + checkMcp: async () => ({ ok: true, tools: 7 }) + })); + assert.match(fresh, /MCP server\s+ready \(7 tools\)/); + assert.match(fresh, /MCP clients\s+claude/); + assert.match(fresh, /Snapshot age\s+up to date/); + + await writeFile(join(projectRoot, "server.ts"), "export const app = false;\n", "utf8"); + const stale = await captureOutput(() => doctorCommand({ + projectRoot, + loadConfig: async () => null, + checkMcp: async () => ({ ok: true, tools: 7 }) + })); + assert.match(stale, /Snapshot age\s+stale/); + assert.match(stale, /devmap_analyze/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("doctor flags a failing MCP self-check as an issue", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-doctor-mcp-fail-test-")); + + try { + const logs = await captureOutput(() => doctorCommand({ + projectRoot, + loadConfig: async () => null, + checkMcp: async () => ({ ok: false, tools: 0, error: "boom" }) + })); + + assert.match(logs, /MCP server\s+failed/); + assert.match(logs, /failed its self-check: boom/); + assert.doesNotMatch(logs, /No issues found/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("the real MCP self-check starts the server and lists every tool", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-doctor-mcp-real-test-")); + + try { + const health = await checkMcpServer(projectRoot); + assert.equal(health.ok, true, health.error); + assert.equal(health.tools, 7); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); From 10a332590b33cec9552063db18801f979acf9149 Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Mon, 2 Nov 2026 06:56:43 +0800 Subject: [PATCH 6/6] test: align explain, MCP explain and JSON doctor tests with the optional key Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRWEEb5zotxkY7QpbA2 --- packages/cli/src/mcp/server.ts | 2 +- packages/cli/test/explain-command.test.ts | 2 +- packages/cli/test/json-output.test.ts | 2 +- packages/cli/test/mcp-explain.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index ac4183e..cdc04cc 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -392,7 +392,7 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { + "using DevMap's index to pick the relevant code. Prefer devmap_map, devmap_flow and devmap_impact for structure; " + "use this for \"what does this do and why\". " + EXPLAIN_NOTE - + " Requires a provider configured with `devmap init`. Spends the user's AI quota.", + + " Requires a provider key set with `devmap config key`. Spends the user's AI quota.", inputSchema: { target: z.string().min(1).describe("A project-relative file path, a feature name, or a top-level function name.") }, diff --git a/packages/cli/test/explain-command.test.ts b/packages/cli/test/explain-command.test.ts index aa40db2..2327642 100644 --- a/packages/cli/test/explain-command.test.ts +++ b/packages/cli/test/explain-command.test.ts @@ -94,7 +94,7 @@ test("explain fails fast with a DevmapError when no provider is configured", asy (error) => { assert.ok(error instanceof DevmapError); assert.match(error.message, /requires an AI provider/); - assert.match(error.hint ?? "", /devmap init/); + assert.match(error.hint ?? "", /devmap config key/); return true; } ); diff --git a/packages/cli/test/json-output.test.ts b/packages/cli/test/json-output.test.ts index 7cbdaab..484f2ec 100644 --- a/packages/cli/test/json-output.test.ts +++ b/packages/cli/test/json-output.test.ts @@ -54,7 +54,7 @@ test("doctor and config JSON outputs contain no formatting noise", async () => { } )); - assert.equal(parseSingleJson(doctorOutput).status, "issues"); + assert.equal(parseSingleJson(doctorOutput).status, "ok"); assert.equal(parseSingleJson(configOutput).model, "openai/gpt-oss-120b"); assert.equal(savedModel, "openai/gpt-oss-120b"); } finally { diff --git a/packages/cli/test/mcp-explain.test.ts b/packages/cli/test/mcp-explain.test.ts index 5569c7e..03e44f4 100644 --- a/packages/cli/test/mcp-explain.test.ts +++ b/packages/cli/test/mcp-explain.test.ts @@ -184,7 +184,7 @@ test("devmap_explain returns an actionable error when no AI provider is configur const result = await callTool(client, "devmap_explain", { target: "lib/auth.ts" }); assert.equal(result.isError, true); assert.match(result.data.error, /requires an AI provider/); - assert.match(result.data.hint, /devmap init/); + assert.match(result.data.hint, /devmap config key/); } finally { await close(); await rm(projectRoot, { recursive: true, force: true });