Skip to content
Open
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ npm-debug.log*

dist/

graphify-out/
graphify-out/
# `trace exports` output — regenerable skill copy + HTML maps, not source
.claude/skills/trace/
30 changes: 17 additions & 13 deletions src/cli/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { writeFileSync } from "node:fs";
import { ManifestCommand } from "./commands/ManifestCommand.js";
import { SchemaCommand } from "./commands/SchemaCommand.js";
import { ServeCommand } from "./commands/ServeCommand.js";
import { ExportSkillCommand } from "./commands/ExportSkillCommand.js";
import { ExportCommand } from "./commands/ExportCommand.js";
import { InputManager } from "../io/InputManager.js";
import { InputError } from "../io/InputError.js";
import { ProcessingManager, EngineAbortError } from "../io/ProcessingManager.js";
Expand Down Expand Up @@ -127,12 +127,15 @@ export class Cli {
// static analysis — code structure without running the app. Each command shells out to one analyzer and
// emits the same Trace envelope as the runtime `run` command (call graph · deps · complexity · symbols).
program.command("graph")
.description("call graph rooted at an entry the flow tree for a function/route, via LSP call hierarchy")
.requiredOption("--entry <ref>", "where to start: file:line, file:line:column, or file@symbol (e.g. src/auth.service.ts:42:9 or src/auth.service.ts@exchangeToken)")
.option("--root <dir>", "project root / LSP workspace (default: auto — nearest tsconfig/package.json/.git above the entry)")
.description("code map via the language server. With --entry file:line / file@symbol: a rooted call graph (the flow tree out of one function). With a directory --entry, or no --entry: a whole-repo map — every symbol with containment (file→class→method), calls, and inheritance (extends/implements).")
.option("--entry <ref>", "rooted call walk: file:line, file:line:column, or file@symbol (e.g. src/auth.service.ts@exchangeToken). A directory — or omitting this — maps the whole repo instead.")
.option("--root <dir>", "project root / LSP workspace (default: auto — nearest tsconfig/package.json/.git; repo mode maps this directory)")
.option("--server <cmd>", "override the LSP server (default: auto by file extension; bundled typescript-language-server for TS/JS, e.g. \"gopls\", \"pyright --stdio\")")
.option("--depth <n>", "max call depth expanded from the entry", parseIntArg, 6)
.option("--html [path]", "also write an interactive call-graph diagram — nodes & edges, force-directed (to a file if a path is given, else a temp file)")
.option("--depth <n>", "rooted mode: max call depth expanded from the entry", parseIntArg, 6)
.option("--max-files <n>", "repo mode: cap on source files scanned (default 800)", parseIntArg)
.option("--include-external", "keep edges to external symbols (node_modules / outside the root) as leaf nodes")
.option("--no-inheritance", "repo mode: skip extends/implements edges (don't query the server's type hierarchy)")
.option("--html [path]", "also write an interactive graph diagram — nodes & edges, force-directed (to a file if a path is given, else a temp file)")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
.action((options) => this.#runGraph(options));

Expand Down Expand Up @@ -188,15 +191,16 @@ export class Cli {
process.exit(0);
});

program.command("export-skill")
.description("copy the bundled `trace` skill into a project's .claude/skills/ so Claude Code picks it up")
.argument("[dir]", "target project root (default: current directory)")
.option("--force", "overwrite an existing .claude/skills/trace")
.action((dir, options) => {
program.command("exports")
.description("provision a project's export directory (.claude/skills/trace): copy the bundled `trace` skill so Claude Code picks it up, AND build interactive HTML maps of the project — the whole-repo LSP map (graph.html) and the module-import graph (deps.html). One command to get everything.")
.argument("[dir]", "target project root (default: current directory) — also the project the maps are built from")
.option("--force", "overwrite an existing export directory (.claude/skills/trace)")
.action(async (dir, options) => {
try {
const { src: source, dest: destination } = new ExportSkillCommand().run({ dir, force: options.force });
const { dest: destination, maps } = await new ExportCommand().run({ dir, force: options.force });
process.stdout.write(`[trace-cli] skill exported → ${destination}\n`);
log.info("skill exported", { src: source, dest: destination });
for (const map of maps) process.stdout.write(`[trace-cli] ${map.kind} map → ${map.path}${map.ok ? "" : " (empty/degraded — see the page)"}\n`);
log.info("export complete", { dest: destination, maps: maps.map((m) => ({ kind: m.kind, ok: m.ok })) });
process.exit(0);
} catch (error: any) {
process.stderr.write(`trace-cli: ${error.message}\n`);
Expand Down
89 changes: 89 additions & 0 deletions src/cli/commands/ExportCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { logger } from "../../shared/logger.js";
import { CliCommand } from "./CliCommand.js";
import { GraphCommand } from "./GraphCommand.js";
import { DepsCommand } from "./DepsCommand.js";

const log = logger.child({ component: "export" });
const GRAPH_DEPTH = 6; // rooted-mode knob; unused in the repo map but GraphRequest requires it
// Keep the deps map about THIS project: drop dependency/build/cache trees (incl. a submodule's build output),
// mirroring the source-discovery ignore set the repo graph already uses. madge --exclude takes a path regexp.
const DEPS_EXCLUDE = "(^|/)(node_modules|dist|build|out|\\.next|\\.turbo|\\.cache|coverage|vendor|__pycache__)/";

export interface ExportRequest {
dir?: string; // target project root (default: cwd) — also the project the maps are built from
force?: boolean; // overwrite an existing export directory (.claude/skills/trace)
}

/** One generated map written into the export directory. `ok` is false when the analysis degraded (page still written). */
export interface ExportedMap { kind: "graph" | "deps"; path: string; ok: boolean }
export interface ExportResult { src: string; dest: string; maps: ExportedMap[] }

/**
* ExportCommand — provision a project's **export directory** (`<dir>/.claude/skills/trace/`) with everything
* trace-cli hands off: the bundled `trace` skill (so Claude Code picks it up) AND interactive HTML maps of the
* project built right then — the whole-repo LSP map (`graph.html`) and the module-import graph (`deps.html`).
* One command, "get everything." The skill copy is the must-succeed step; each map is best-effort (a failed or
* empty analysis still writes a page and is reported via `ok`, never aborting the export).
*/
export class ExportCommand extends CliCommand<ExportRequest, ExportResult> {
static readonly SKILL_NAME = "trace";

/** Locate the bundled `skills/trace` dir across run modes (dist build, plugin, repo cwd). */
#resolveSource(): string {
const candidatePaths = [
// dist/cli/commands/ExportCommand.js → package root → skills/trace
fileURLToPath(new URL("../../../skills/trace", import.meta.url)),
...(process.env.CLAUDE_PLUGIN_ROOT ? [join(process.env.CLAUDE_PLUGIN_ROOT, "skills", "trace")] : []),
join(process.cwd(), "skills", "trace"),
];
const foundPath = candidatePaths.find((candidatePath) => existsSync(join(candidatePath, "SKILL.md")));
if (!foundPath) throw new Error(`bundled '${ExportCommand.SKILL_NAME}' skill not found (looked in: ${candidatePaths.join(", ")})`);
return foundPath;
}

/** Build one HTML map into the export directory; degrade to a written page (ok:false) rather than throwing. */
async #writeMap(kind: ExportedMap["kind"], exportDir: string, build: () => Promise<{ html: string; ok: boolean }>): Promise<ExportedMap> {
const path = join(exportDir, `${kind}.html`);
try {
const { html, ok } = await build();
writeFileSync(path, html);
return { kind, path, ok };
} catch (error) {
log.warn(`${kind} map failed`, { err: String((error as Error)?.message ?? error).split("\n")[0] });
return { kind, path, ok: false };
}
Comment on lines +55 to +58
}

async run(request: ExportRequest = {}): Promise<ExportResult> {
const sourcePath = this.#resolveSource();
const projectRoot = resolve(request.dir ?? process.cwd());
const skillsDirectory = join(projectRoot, ".claude", "skills");
const exportDir = join(skillsDirectory, ExportCommand.SKILL_NAME);
if (existsSync(exportDir) && !request.force) {
throw new Error(`${exportDir} already exists — pass --force to overwrite`);
}

// 1. the skill (must succeed) — Claude Code discovers it at .claude/skills/trace.
mkdirSync(skillsDirectory, { recursive: true });
cpSync(sourcePath, exportDir, { recursive: true, force: true });

// 2. the maps of THIS project, built into the same export directory (best-effort).
const maps = [
await this.#writeMap("graph", exportDir, async () => {
const trace = await new GraphCommand().run({ repo: true, root: projectRoot, maxDepth: GRAPH_DEPTH });
return { html: new GraphCommand().renderHtml(trace), ok: trace.ok };
}),
await this.#writeMap("deps", exportDir, async () => {
const command = new DepsCommand();
const trace = await command.run({ entry: projectRoot, root: projectRoot, exclude: DEPS_EXCLUDE, args: { entry: projectRoot } });
return { html: command.renderHtml(trace), ok: trace.ok };
}),
];

return { src: sourcePath, dest: exportDir, maps };
}
}
48 changes: 0 additions & 48 deletions src/cli/commands/ExportSkillCommand.ts

This file was deleted.

Loading