Skip to content

Commit ee2bbae

Browse files
author
chenbo
committed
Split CLI command registry
1 parent 4afe017 commit ee2bbae

19 files changed

Lines changed: 1607 additions & 1510 deletions

src/cli/commands/benchmark.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Command } from "commander";
2+
import { parseAgentBenchmarkModes, renderAgentBehaviorBenchmark, runAgentBehaviorBenchmark } from "../../benchmarks/agent-benchmark.js";
3+
import { renderBenchmarkReport, runBenchmark } from "../../benchmarks/benchmark.js";
4+
import type { AgentExecutorName } from "../../harness/control-plane/orchestrator.js";
5+
import type { PolicyFailOn } from "../../harness/verification-plane/policy-engine.js";
6+
import { parseAgentExecutor, parseInteger, parsePolicyFailOn, splitCsv } from "../parsers/options.js";
7+
8+
export function registerBenchmarkCommands(program: Command): void {
9+
program
10+
.command("benchmark")
11+
.argument("[benchmarkDir]", "benchmark directory", "benchmarks")
12+
.option("-k, --top-k <count>", "top-K files used for recall/precision", parseInteger, 8)
13+
.option("--json", "print machine-readable benchmark results")
14+
.description("Run the loop behavior benchmark over benchmark fixtures.")
15+
.action(async (benchmarkDir: string, options: { topK: number; json?: boolean }) => {
16+
const result = await runBenchmark({ benchmarkDir, topK: options.topK });
17+
console.log(options.json ? JSON.stringify(result, null, 2) : renderBenchmarkReport(result));
18+
});
19+
20+
program
21+
.command("benchmark-agent")
22+
.argument("[benchmarkDir]", "benchmark directory", "benchmarks")
23+
.option("--executor <executor>", "executor: codex, claude-code, opencode, mimocode, cursor, mock", parseAgentExecutor, "mock")
24+
.option("--executor-command <command>", "argv-style command; supports {prompt}, {task}, {repo}, {runDir}, {agent}")
25+
.option("--agent <agent>", "executor-specific agent/profile name")
26+
.option("--max-loops <count>", "maximum loop count for harness-led mode", parseInteger, 3)
27+
.option("--fail-on <level>", "policy failure threshold: forbidden, required, risk", parsePolicyFailOn, "required")
28+
.option("--base <ref>", "base git ref created in each fixture workspace", "main")
29+
.option("--modes <modes>", "comma-separated modes: no-context, agents-md, context-pack, loop-enabled-harness", parseAgentBenchmarkModes)
30+
.option("--task <ids>", "comma-separated task ids to run")
31+
.option("--dry-run", "exercise executor paths without editing files")
32+
.option("--keep-workdirs", "keep temporary fixture workdirs for inspection")
33+
.option("--json", "print machine-readable agent behavior benchmark results")
34+
.description("Run the real-agent behavior benchmark across context modes using a selected executor.")
35+
.action(async (benchmarkDir: string, options: AgentBenchmarkCliOptions) => {
36+
const result = await runAgentBehaviorBenchmark({
37+
benchmarkDir,
38+
executor: options.executor,
39+
executorCommand: options.executorCommand,
40+
agent: options.agent,
41+
maxLoops: options.maxLoops,
42+
failOn: options.failOn,
43+
base: options.base,
44+
modes: options.modes,
45+
taskIds: splitCsv(options.task),
46+
dryRun: options.dryRun,
47+
keepWorkdirs: options.keepWorkdirs
48+
});
49+
console.log(options.json ? JSON.stringify(result, null, 2) : renderAgentBehaviorBenchmark(result));
50+
});
51+
}
52+
53+
interface AgentBenchmarkCliOptions {
54+
executor: AgentExecutorName;
55+
executorCommand?: string;
56+
agent?: string;
57+
maxLoops: number;
58+
failOn: PolicyFailOn;
59+
base: string;
60+
modes?: ReturnType<typeof parseAgentBenchmarkModes>;
61+
task?: string;
62+
dryRun?: boolean;
63+
keepWorkdirs?: boolean;
64+
json?: boolean;
65+
}

src/cli/commands/context.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { existsSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
import type { Command } from "commander";
4+
import type { AgentTarget, CacheStats, IndexedFile } from "../../core/types.js";
5+
import { buildContextPackage } from "../../core/context-builder.js";
6+
import { changedFilesSince } from "../../core/git.js";
7+
import { validateContextPackage } from "../../core/validator.js";
8+
import { assessDrift, assessFreshness, buildContextManifest, renderDriftReport, renderFreshnessReport } from "../../core/freshness.js";
9+
import { summarizeReadiness } from "../../core/readiness.js";
10+
import { parseTokenizerMode } from "../../core/token-estimator.js";
11+
import { formatTokenSavings } from "../../core/token-savings.js";
12+
import { starterConfig } from "../../config/starter-config.js";
13+
import { buildContextDelta, renderContextDelta, writeContextDelta } from "../../outputs/context-delta.js";
14+
import { renderDependencyGraph } from "../../outputs/dependency-graph.js";
15+
import { writeContextPackage } from "../../outputs/renderers/writer.js";
16+
import { parseInteger, parseTarget } from "../parsers/options.js";
17+
18+
export function registerContextCommands(program: Command): void {
19+
program
20+
.command("build")
21+
.argument("[repo]", "repository path", ".")
22+
.option("-t, --target <target>", "agent target: opencode, codex, claude, cursor, all", parseTarget)
23+
.option("-b, --token-budget <tokens>", "target token budget", parseInteger)
24+
.option("--tokenizer <tokenizer>", "tokenizer: chars-approx, cl100k_base, o200k_base", parseTokenizerMode)
25+
.option("--model <model>", "model name used to infer tokenizer, for example gpt-4.1")
26+
.option("--llm", "enable LLM summaries using opencode-plusplus.local.yml")
27+
.option("--no-llm", "disable LLM summaries")
28+
.description("Generate AGENTS.md and .agent-context outputs.")
29+
.action(
30+
async (
31+
repo: string,
32+
options: { target?: AgentTarget; tokenBudget?: number; tokenizer?: ReturnType<typeof parseTokenizerMode>; model?: string; llm?: boolean }
33+
) => {
34+
const context = await buildContextPackage(repo, options);
35+
const result = writeContextPackage(context);
36+
console.log(`Generated agent context for ${context.scan.root}`);
37+
console.log(`Files scanned: ${context.scan.files.length}`);
38+
console.log(`Languages: ${context.scan.languages.join(", ") || "none detected"}`);
39+
console.log(`Key files: ${context.keyFiles.length}`);
40+
console.log(`Agent readiness: ${context.readiness.grade} / ${context.readiness.score}`);
41+
console.log(`Summary mode: ${context.summaries.mode}`);
42+
if (context.summaries.fallbackReason && context.summaries.fallbackReason !== "disabled") {
43+
console.log(`LLM fallback reason: ${context.summaries.fallbackReason}`);
44+
}
45+
console.log(formatTokenSavings(context.tokenSavings));
46+
console.log("Written:");
47+
for (const file of result.files) {
48+
console.log(`- ${path.relative(context.scan.root, file)}`);
49+
}
50+
}
51+
);
52+
53+
program
54+
.command("savings")
55+
.argument("[repo]", "repository path", ".")
56+
.option("-b, --token-budget <tokens>", "target token budget", parseInteger)
57+
.option("--actual", "write the context package first and report actual generated output tokens")
58+
.option("--tokenizer <tokenizer>", "tokenizer: chars-approx, cl100k_base, o200k_base", parseTokenizerMode)
59+
.option("--model <model>", "model name used to infer tokenizer, for example gpt-4.1")
60+
.description("Print the token savings report.")
61+
.action(async (repo: string, options: { tokenBudget?: number; actual?: boolean; tokenizer?: ReturnType<typeof parseTokenizerMode>; model?: string }) => {
62+
const context = await buildContextPackage(repo, options);
63+
if (options.actual) {
64+
writeContextPackage(context);
65+
}
66+
console.log(formatTokenSavings(context.tokenSavings));
67+
});
68+
69+
program
70+
.command("init")
71+
.argument("[repo]", "repository path", ".")
72+
.description("Create a starter opencode-plusplus.config.yml.")
73+
.action((repo: string) => {
74+
const root = path.resolve(repo);
75+
const configPath = path.join(root, "opencode-plusplus.config.yml");
76+
77+
if (existsSync(configPath)) {
78+
console.log(`Config already exists: ${configPath}`);
79+
return;
80+
}
81+
82+
writeFileSync(configPath, starterConfig(), "utf8");
83+
console.log(`Created ${configPath}`);
84+
});
85+
86+
program
87+
.command("graph")
88+
.argument("[repo]", "repository path", ".")
89+
.description("Print the generated dependency graph markdown.")
90+
.action(async (repo: string) => {
91+
const context = await buildContextPackage(repo);
92+
console.log(renderDependencyGraph(context));
93+
});
94+
95+
program
96+
.command("readiness")
97+
.argument("[repo]", "repository path", ".")
98+
.description("Print the agent readiness score and missing context signals.")
99+
.action(async (repo: string) => {
100+
const context = await buildContextPackage(repo);
101+
console.log(summarizeReadiness(context));
102+
});
103+
104+
program
105+
.command("validate")
106+
.argument("[repo]", "repository path", ".")
107+
.description("Validate config, generated JSON, dependency edges, confidence, and token budget.")
108+
.action(async (repo: string) => {
109+
const context = await buildContextPackage(repo);
110+
const report = validateContextPackage(context);
111+
console.log(report.valid ? "Validation: passed" : "Validation: failed");
112+
for (const issue of report.issues) {
113+
console.log(`- ${issue.severity.toUpperCase()} ${issue.code}: ${issue.message}`);
114+
}
115+
if (!report.valid) process.exitCode = 1;
116+
});
117+
118+
program
119+
.command("freshness")
120+
.argument("[repo]", "repository path", ".")
121+
.option("--json", "print machine-readable freshness report")
122+
.description("Check whether AGENTS.md and .agent-context were generated from the current source, config, and commit.")
123+
.action(async (repo: string, options: { json?: boolean }) => {
124+
const context = await buildContextPackage(repo);
125+
const report = assessFreshness(context);
126+
console.log(options.json ? JSON.stringify(report, null, 2) : renderFreshnessReport(report));
127+
if (report.status !== "fresh") process.exitCode = 1;
128+
});
129+
130+
program
131+
.command("drift")
132+
.argument("[repo]", "repository path", ".")
133+
.option("--json", "print machine-readable drift report")
134+
.description("Detect stale generated context, dependency graph, task pack, and contract drift.")
135+
.action(async (repo: string, options: { json?: boolean }) => {
136+
const context = await buildContextPackage(repo);
137+
const report = assessDrift(context);
138+
console.log(options.json ? JSON.stringify(report, null, 2) : renderDriftReport(report));
139+
if (report.status !== "clean") process.exitCode = 1;
140+
});
141+
142+
program
143+
.command("delta")
144+
.argument("[repo]", "repository path", ".")
145+
.option("--base <ref>", "base git ref for context delta analysis", "main")
146+
.option("--json", "print machine-readable context delta")
147+
.description("Show what changed, which context outputs are stale, and what an agent must re-read.")
148+
.action(async (repo: string, options: { base: string; json?: boolean }) => {
149+
const context = await buildContextPackage(repo);
150+
const report = buildContextDelta(context, { base: options.base });
151+
console.log(options.json ? JSON.stringify(report, null, 2) : renderContextDelta(report));
152+
});
153+
154+
program
155+
.command("evolve")
156+
.argument("[repo]", "repository path", ".")
157+
.option("--base <ref>", "base git ref for context delta analysis", "main")
158+
.option("--json", "print machine-readable evolve report after updating context")
159+
.description("Refresh the agent context with cache-aware full output rebuild and write .agent-context/delta/latest.*.")
160+
.action(async (repo: string, options: { base: string; json?: boolean }) => {
161+
const context = await buildContextPackage(repo);
162+
const delta = buildContextDelta(context, { base: options.base });
163+
const result = writeContextPackage(context);
164+
const deltaResult = writeContextDelta(context, delta);
165+
const rewrittenOutputs = [...result.files, ...deltaResult.files].map((file) => path.relative(context.scan.root, file).replaceAll("\\", "/"));
166+
const evolveReport = {
167+
mode: "cache-aware-full-refresh",
168+
selectiveWrite: false,
169+
note: "evolve currently reuses scan/index/graph/token caches, then refreshes the full generated context plus delta reports. Selective output writes are planned.",
170+
cache: summarizeCacheStats(context.cacheStats),
171+
delta,
172+
rewrittenOutputs
173+
};
174+
writeFileSync(
175+
path.join(context.scan.root, ".agent-context", "manifest.json"),
176+
`${JSON.stringify(buildContextManifest(context, [...result.files, ...deltaResult.files]), null, 2)}\n`,
177+
"utf8"
178+
);
179+
if (options.json) {
180+
console.log(JSON.stringify(evolveReport, null, 2));
181+
return;
182+
}
183+
console.log(renderContextDelta(delta));
184+
console.log("");
185+
console.log("Evolve mode: cache-aware full refresh (selective output writes: planned)");
186+
console.log(`Cache: ${formatCacheStats(context.cacheStats)}`);
187+
console.log(`Rewritten outputs: ${rewrittenOutputs.length}`);
188+
for (const file of rewrittenOutputs.slice(0, 12)) console.log(`- ${file}`);
189+
if (rewrittenOutputs.length > 12) console.log(`- ... ${rewrittenOutputs.length - 12} more`);
190+
});
191+
192+
program
193+
.command("diff")
194+
.argument("[repo]", "repository path", ".")
195+
.option("--base <ref>", "base git ref", "main")
196+
.description("Generate context for files changed since a git base ref.")
197+
.action(async (repo: string, options: { base: string }) => {
198+
const context = await buildContextPackage(repo);
199+
const changed = new Set(changedFilesSince(context.scan.root, options.base));
200+
const files = context.index.files.filter((file) => changed.has(file.path));
201+
console.log("# Diff Context");
202+
console.log("");
203+
console.log(`Base: ${options.base}`);
204+
console.log("");
205+
printFileList(files);
206+
});
207+
208+
program
209+
.command("update")
210+
.argument("[repo]", "repository path", ".")
211+
.option("--since <ref>", "show changed files since a git ref after rebuilding", "main")
212+
.description("Rebuild the context package and report files changed since a git ref.")
213+
.action(async (repo: string, options: { since: string }) => {
214+
const context = await buildContextPackage(repo);
215+
const result = writeContextPackage(context);
216+
const changed = changedFilesSince(context.scan.root, options.since);
217+
console.log(`Updated agent context for ${context.scan.root}`);
218+
console.log(`Written files: ${result.files.length}`);
219+
console.log(`Changed since ${options.since}:`);
220+
for (const file of changed) {
221+
console.log(`- ${file}`);
222+
}
223+
});
224+
225+
program
226+
.command("explain")
227+
.argument("<path>", "file path or module name to explain")
228+
.argument("[repo]", "repository path", ".")
229+
.description("Explain a file or module from the generated repository index.")
230+
.action(async (targetPath: string, repo: string) => {
231+
const context = await buildContextPackage(repo);
232+
const normalized = targetPath.replace(/\\/g, "/");
233+
const file = context.index.files.find((candidate) => candidate.path === normalized);
234+
if (file) {
235+
console.log(`# ${file.path}`);
236+
console.log("");
237+
console.log(file.summary);
238+
console.log(`Kind: ${file.kind}`);
239+
console.log(`Module: ${file.moduleName}`);
240+
console.log(`Analyzer: ${file.analyzer} (${file.confidence} confidence)`);
241+
console.log(
242+
`Analysis stats: ${file.analysisStats.parser}, imports ${file.analysisStats.importsResolved}/${file.imports.length} resolved, ${file.analysisStats.routesDetected} routes`
243+
);
244+
console.log(`Importance: ${file.importanceScore} (${file.importanceReasons.join(", ") || "no ranking signals"})`);
245+
console.log(`Imports: ${file.imports.map((item) => item.specifier).join(", ") || "none"}`);
246+
console.log(`Exports: ${file.exports.join(", ") || "none"}`);
247+
return;
248+
}
249+
250+
const module = context.index.modules.find((candidate) => candidate.name === normalized);
251+
if (module) {
252+
console.log(`# ${module.name}`);
253+
console.log("");
254+
console.log(module.summary);
255+
console.log(`Files: ${module.files.length}`);
256+
console.log(`Depends on: ${module.imports.join(", ") || "none"}`);
257+
for (const moduleFile of module.files.slice(0, 30)) {
258+
console.log(`- ${moduleFile}`);
259+
}
260+
return;
261+
}
262+
263+
console.error(`No file or module found for: ${targetPath}`);
264+
process.exitCode = 1;
265+
});
266+
}
267+
268+
function summarizeCacheStats(stats: CacheStats) {
269+
return {
270+
enabled: stats.enabled,
271+
reusedIndexedFiles: stats.indexHits,
272+
reindexedFiles: stats.indexMisses,
273+
reusedFileHashes: stats.fileHashHits,
274+
recalculatedFileHashes: stats.fileHashMisses,
275+
graphReused: stats.graphHits > 0,
276+
graphRebuilt: stats.graphMisses > 0,
277+
tokenCacheHits: stats.tokenHits,
278+
tokenCacheMisses: stats.tokenMisses,
279+
prunedFileHashes: stats.prunedFileHashes,
280+
prunedIndexEntries: stats.prunedIndexEntries
281+
};
282+
}
283+
284+
function formatCacheStats(stats: CacheStats): string {
285+
if (!stats.enabled) return "disabled";
286+
const summary = summarizeCacheStats(stats);
287+
return [
288+
`reused indexed files ${summary.reusedIndexedFiles}`,
289+
`re-indexed files ${summary.reindexedFiles}`,
290+
`graph rebuilt ${summary.graphRebuilt ? "yes" : "no"}`,
291+
`token hits ${summary.tokenCacheHits}`,
292+
`token misses ${summary.tokenCacheMisses}`
293+
].join("; ");
294+
}
295+
296+
function printFileList(files: IndexedFile[]): void {
297+
if (!files.length) {
298+
console.log("No matching files detected.");
299+
return;
300+
}
301+
302+
for (const file of files.sort((a, b) => b.importanceScore - a.importanceScore || a.path.localeCompare(b.path))) {
303+
console.log(`- ${file.path}`);
304+
console.log(` - Score: ${file.importanceScore}`);
305+
console.log(` - Summary: ${file.summary}`);
306+
}
307+
}

src/cli/commands/doctor.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Command } from "commander";
2+
import { renderOpenCodePlusplusDoctor, runOpenCodePlusplusDoctor } from "../opencode-plusplus-commands.js";
3+
4+
export function registerDoctorCommand(program: Command): void {
5+
program
6+
.command("doctor")
7+
.argument("[repo]", "repository path", ".")
8+
.option("--json", "print machine-readable doctor report")
9+
.description("Check OpenCode, auth, git, context, and OpenCode++ sidecar readiness.")
10+
.action(async (repo: string, options: { json?: boolean }) => {
11+
const report = await runOpenCodePlusplusDoctor(repo);
12+
console.log(options.json ? JSON.stringify(report, null, 2) : renderOpenCodePlusplusDoctor(report));
13+
if (!report.ok) process.exitCode = 1;
14+
});
15+
}

0 commit comments

Comments
 (0)