Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/cli/src/cache/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ export async function isSnapshotStale(
return createProjectFingerprint(files) !== snapshot.fingerprint;
}

export type LoadedSnapshot = {
snapshot: ProjectMap;
/** True when the project files changed since the snapshot was generated. */
stale: boolean;
};

/**
* Read the snapshot (throwing a DevmapError with a hint when it is missing,
* corrupt, or unsupported) together with its staleness. Read-only.
*/
export async function loadSnapshot(projectRoot: string): Promise<LoadedSnapshot> {
const snapshot = await readSnapshotOrThrow(projectRoot);
const stale = await isSnapshotStale(projectRoot, snapshot);
return { snapshot, stale };
}

export async function readSnapshot(projectRoot: string): Promise<ProjectMap | null> {
const result = await inspectSnapshot(projectRoot);
return result.status === "valid" ? result.snapshot : null;
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ export async function explainCommand(
}
}

async function runExplain(
/**
* Core explain computation, shared by the CLI and other front-ends (e.g. an MCP server).
* Requires a configured AI provider. Only writes `.devmap/explain/*` when
* `options.write` is true. Pass `json: true` (and call inside `withJsonOutput`)
* to disable terminal streaming.
*/
export async function runExplain(
target: string,
options: ExplainOptions,
dependencies: ExplainDependencies
Expand Down
67 changes: 46 additions & 21 deletions packages/cli/src/commands/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type FlowInfo,
type ProjectMap
} from "../analyzers/pipeline/index.js";
import { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js";
import { loadSnapshot } from "../cache/snapshot.js";
import { resolveEffectiveConfig, type DevmapConfig } from "../utils/config.js";
import { DevmapError } from "../utils/errors.js";
import { buildMapMarkdown } from "../utils/mapRenderer.js";
Expand All @@ -25,6 +25,16 @@ export type FlowOptions = {
json?: boolean;
projectRoot?: string;
all?: boolean;
/**
* Persist flows under `.devmap/flows/`. Defaults to true (CLI behavior).
* Pass `false` to compute flows without touching the disk.
*/
write?: boolean;
/**
* Add AI narration when an AI provider is configured. Defaults to true.
* Pass `false` for a purely static, offline result.
*/
narrate?: boolean;
};

export type FlowDependencies = {
Expand Down Expand Up @@ -76,36 +86,47 @@ export async function flowCommand(
}
}

async function runFlow(
/**
* Core flow computation, shared by the CLI and other front-ends (e.g. an MCP server).
* Human-facing notes go through `output.*`, so call it inside `withJsonOutput`
* to keep stdout clean. Writes `.devmap/flows/*` unless `options.write === false`;
* skips AI entirely when `options.narrate === false`.
*/
export async function runFlow(
target: string | undefined,
options: FlowOptions,
dependencies: FlowDependencies
): Promise<FlowResult> {
const projectRoot = resolve(options.projectRoot ?? ".");
const snapshot = await readSnapshotOrThrow(projectRoot);
const stale = await isSnapshotStale(projectRoot, snapshot);
const { snapshot, stale } = await loadSnapshot(projectRoot);

const flows = options.all
? buildAllFlows(snapshot)
: snapshot.flows;
const resolved = resolveFlowTarget(flows, target);

const loadConfig = dependencies.loadConfig
?? (() => resolveEffectiveConfig(projectRoot));
const config = await loadConfig();
const shouldWrite = options.write !== false;
let client: AiClient | undefined;
let routing: { model: string; fallbackModels: readonly string[] } | undefined;

if (!config?.apiKey) {
output.note("AI flow narration is not configured. Run devmap init to enable it.");
} else {
const createAiClient = dependencies.createAiClient ?? createDefaultAiClient;
client = createAiClient(config);
routing = resolveAiRouting(config, "flowNarration");
if (options.narrate !== false) {
const loadConfig = dependencies.loadConfig
?? (() => resolveEffectiveConfig(projectRoot));
const config = await loadConfig();

if (!config?.apiKey) {
output.note("AI flow narration is not configured. Run devmap init to enable it.");
} else {
const createAiClient = dependencies.createAiClient ?? createDefaultAiClient;
client = createAiClient(config);
routing = resolveAiRouting(config, "flowNarration");
}
}

const flowsDir = join(projectRoot, ".devmap", "flows");
await mkdir(flowsDir, { recursive: true });
if (shouldWrite) {
await mkdir(flowsDir, { recursive: true });
}

const built: Array<{
name: string;
Expand Down Expand Up @@ -137,8 +158,10 @@ async function runFlow(
const mermaid = flow.mermaid ?? renderMermaidFlow(flow.steps);
const slug = slugifyMapName(flow.name);

await writeFile(join(flowsDir, `${slug}.md`), markdown, "utf8");
await writeFile(join(flowsDir, `${slug}.mermaid`), `${mermaid}\n`, "utf8");
if (shouldWrite) {
await writeFile(join(flowsDir, `${slug}.md`), markdown, "utf8");
await writeFile(join(flowsDir, `${slug}.mermaid`), `${mermaid}\n`, "utf8");
}

built.push({
name: flow.name,
Expand All @@ -154,11 +177,13 @@ async function runFlow(
return {
status: "ok",
flows: built.map(({ slug: _slug, ...rest }) => rest),
writtenPaths: built.map(({ name, slug }) => ({
name,
markdown: `.devmap/flows/${slug}.md`,
mermaid: `.devmap/flows/${slug}.mermaid`
})),
writtenPaths: shouldWrite
? built.map(({ name, slug }) => ({
name,
markdown: `.devmap/flows/${slug}.md`,
mermaid: `.devmap/flows/${slug}.mermaid`
}))
: [],
snapshot: { generatedAt: snapshot.generatedAt, stale }
};
}
Expand Down
49 changes: 31 additions & 18 deletions packages/cli/src/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type MapTreeNode
} from "../analyzers/graph/index.js";
import type { ProjectMap } from "../analyzers/pipeline/index.js";
import { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js";
import { loadSnapshot } from "../cache/snapshot.js";
import { DevmapError } from "../utils/errors.js";
import { slugifyMapName } from "../utils/slug.js";
import { resolveFileTarget } from "../utils/targetResolver.js";
Expand All @@ -30,6 +30,11 @@ export type MapOptions = {
target?: string;
depth?: number;
all?: boolean;
/**
* Persist the map under `.devmap/maps/`. Defaults to true (CLI behavior).
* Pass `false` to compute the map without touching the disk.
*/
write?: boolean;
};

export type MapResult = {
Expand All @@ -38,7 +43,8 @@ export type MapResult = {
target: string;
markdown: string;
mermaid: string;
writtenPaths: { markdown: string; mermaid: string };
/** Absent when the map was computed with `write: false`. */
writtenPaths?: { markdown: string; mermaid: string };
snapshot: { generatedAt: string; stale: boolean };
};

Expand All @@ -60,17 +66,22 @@ export async function mapCommand(
output.note("Run devmap analyze --fresh, then repeat devmap map.");
}
output.markdown(result.markdown);
output.success(`Wrote ${result.writtenPaths.markdown}`);
output.success(`Wrote ${result.writtenPaths.mermaid}`);
if (result.writtenPaths) {
output.success(`Wrote ${result.writtenPaths.markdown}`);
output.success(`Wrote ${result.writtenPaths.mermaid}`);
}
}

async function runMap(
/**
* Core map computation, shared by the CLI and other front-ends (e.g. an MCP server).
* Never prints. Writes `.devmap/maps/*` unless `options.write === false`.
*/
export async function runMap(
target: string | undefined,
options: MapOptions
): Promise<MapResult> {
const projectRoot = resolve(options.projectRoot ?? ".");
const snapshot = await readSnapshotOrThrow(projectRoot);
const stale = await isSnapshotStale(projectRoot, snapshot);
const { snapshot, stale } = await loadSnapshot(projectRoot);

const resolved = resolveMapTarget(snapshot, target);

Expand All @@ -80,25 +91,27 @@ async function runMap(
? buildFeatureMap(snapshot, resolved.value, options.depth, options.all)
: buildProjectMap(snapshot, options.all);

const slug = slugifyMapName(resolved.value);
const mapsDir = join(projectRoot, ".devmap", "maps");
await mkdir(mapsDir, { recursive: true });
let writtenPaths: MapResult["writtenPaths"];
if (options.write !== false) {
const slug = slugifyMapName(resolved.value);
const mapsDir = join(projectRoot, ".devmap", "maps");
await mkdir(mapsDir, { recursive: true });

const markdownPath = join(mapsDir, `${slug}.md`);
const mermaidPath = join(mapsDir, `${slug}.mermaid`);
await writeFile(markdownPath, built.markdown, "utf8");
await writeFile(mermaidPath, `${built.mermaid}\n`, "utf8");
await writeFile(join(mapsDir, `${slug}.md`), built.markdown, "utf8");
await writeFile(join(mapsDir, `${slug}.mermaid`), `${built.mermaid}\n`, "utf8");
writtenPaths = {
markdown: `.devmap/maps/${slug}.md`,
mermaid: `.devmap/maps/${slug}.mermaid`
};
}

return {
status: "ok",
mode: resolved.mode,
target: resolved.value,
markdown: built.markdown,
mermaid: built.mermaid,
writtenPaths: {
markdown: `.devmap/maps/${slug}.md`,
mermaid: `.devmap/maps/${slug}.mermaid`
},
...(writtenPaths ? { writtenPaths } : {}),
snapshot: { generatedAt: snapshot.generatedAt, stale }
};
}
Expand Down
Loading
Loading