@open-cr-agent/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ import { coverageGaps, serializeOutput, toReportOutput, } from "@open-cr-agent/core";
2
+ import { forTerminal } from "./terminal.js";
3
+ const SEVERITIES = ["critical", "warning", "suggestion"];
4
+ const VERIFICATION = {
5
+ confirmed: "verified",
6
+ uncertain: "unverified",
7
+ unchecked: "not verified",
8
+ };
9
+ // JSON.stringify escapes C0 controls but not C1 controls or bidirectional
10
+ // overrides, which a terminal or an editor would act on.
11
+ export function renderJson(report) {
12
+ return `${safeJson(toReportOutput(report))}\n`;
13
+ }
14
+ export const safeJson = serializeOutput;
15
+ export function renderText(report, sessionDir) {
16
+ const incomplete = report.tasks.filter((t) => t.status !== "completed");
17
+ const { notReviewed, nothingReviewed } = coverageGaps(report.coverage);
18
+ const lines = [
19
+ `Review: ${report.changeRequest.title}`,
20
+ coverageLine(report),
21
+ ...(nothingReviewed
22
+ ? ["Verdict: not reached (nothing was reviewed)"]
23
+ : [`Verdict: ${report.verdict.replaceAll("_", " ")}`, ...indented(report.summary, " ")]),
24
+ "",
25
+ ];
26
+ if (report.findings.length === 0) {
27
+ lines.push(emptyMessage(report, nothingReviewed, notReviewed), "");
28
+ }
29
+ else {
30
+ for (const [file, findings] of groupByFile(report.findings)) {
31
+ lines.push(file);
32
+ for (const finding of findings)
33
+ lines.push(...renderFinding(finding));
34
+ lines.push("");
35
+ }
36
+ }
37
+ lines.push(summaryLine(report));
38
+ const override = report.changeRequest.override;
39
+ if (override && report.verdict === "significant_concerns") {
40
+ lines.push(`Overridden by ${override.by}: ${override.reason}`);
41
+ }
42
+ // Only true while no confirmed critical blocks, and low-confidence
43
+ // findings do not count at all.
44
+ const unverified = report.verdict === "significant_concerns"
45
+ ? 0
46
+ : report.findings.filter((f) => f.severity === "critical" && f.verification !== "confirmed" && !f.lowConfidence).length;
47
+ if (unverified > 0) {
48
+ lines.push(`${unverified} critical finding(s) are not verified, so the verdict is at most minor issues.`);
49
+ }
50
+ if (report.unverifiedCriticals > 0) {
51
+ lines.push(`Incomplete: verification failed or ran out of budget for ${report.unverifiedCriticals} critical finding(s); check them yourself.`);
52
+ }
53
+ const { refuted } = report;
54
+ if (refuted.length > 0) {
55
+ lines.push(`Verification dropped ${refuted.length} finding(s) the code disproves (see the JSON report).`);
56
+ }
57
+ if (report.remembered.length > 0) {
58
+ lines.push(`${report.remembered.length} finding(s) matched the repository's memory and were not reported.`);
59
+ }
60
+ const rereview = report.rereview;
61
+ if (report.scope?.mode === "incremental") {
62
+ lines.push(`Reviewed only what changed since ${report.scope.since.slice(0, 7)}.`);
63
+ }
64
+ else if (report.scope) {
65
+ lines.push(`Reviewed every file again: ${report.scope.reason}.`);
66
+ }
67
+ if (rereview) {
68
+ lines.push(`Since the last review: ${rereview.fixed.length} fixed, ${rereview.dismissed.length} dismissed by reviewers, ${rereview.notReproduced.length} not reported again but unchanged, ${rereview.notRechecked.length} not re-checked, ${rereview.unchanged.length} in unchanged files (still open ones count in the verdict).`);
69
+ }
70
+ const ambiguous = report.anchoring?.ambiguous ?? 0;
71
+ if (ambiguous > 0) {
72
+ lines.push(`${ambiguous} finding(s) quote code that appears in several places; shown without a line.`);
73
+ }
74
+ const judged = report.judgement;
75
+ if (judged && judged.merged.length + judged.dropped.length > 0) {
76
+ lines.push(`Judge merged ${judged.merged.length} duplicate group(s) and dropped ${judged.dropped.length} finding(s) (see the JSON report).`);
77
+ }
78
+ if (incomplete.length > 0) {
79
+ lines.push(`Incomplete: ${incomplete.length} of ${report.tasks.length} review task(s) did not finish.`);
80
+ }
81
+ if (notReviewed > 0) {
82
+ lines.push(`Incomplete: ${notReviewed} selected file(s) were not reviewed.`);
83
+ }
84
+ for (const warning of report.warnings)
85
+ lines.push(`Warning: ${warning}`);
86
+ if (sessionDir)
87
+ lines.push(`Session: ${sessionDir}`);
88
+ return forTerminal(`${lines.join("\n")}\n`);
89
+ }
90
+ function emptyMessage(report, nothingReviewed, notReviewed) {
91
+ if (nothingReviewed)
92
+ return "Nothing was reviewed.";
93
+ if (notReviewed > 0)
94
+ return "No issues found in the files that were reviewed.";
95
+ const open = report.rereview
96
+ ? report.rereview.notReproduced.length +
97
+ report.rereview.notRechecked.length +
98
+ report.rereview.unchanged.length
99
+ : 0;
100
+ if (open > 0)
101
+ return `No new issues; ${open} earlier finding(s) are still open.`;
102
+ if (!report.coverage.some((c) => c.status !== "excluded")) {
103
+ return "Nothing to review: no changed file was selected.";
104
+ }
105
+ return "No issues found.";
106
+ }
107
+ function coverageLine(report) {
108
+ const count = (status) => report.coverage.filter((c) => c.status === status).length;
109
+ const unreviewed = count("unreviewed");
110
+ return [
111
+ `Risk tier: ${report.tier}`,
112
+ `${count("reviewed")} reviewed`,
113
+ `${count("failed")} failed`,
114
+ ...(unreviewed > 0 ? [`${unreviewed} not covered by any reviewer`] : []),
115
+ ...(count("unchanged") > 0 ? [`${count("unchanged")} unchanged since the last review`] : []),
116
+ `${count("excluded")} excluded`,
117
+ ].join(" · ");
118
+ }
119
+ function renderFinding(finding) {
120
+ const location = finding.lineRange
121
+ ? finding.lineRange.start === finding.lineRange.end
122
+ ? `L${finding.lineRange.start}`
123
+ : `L${finding.lineRange.start}-${finding.lineRange.end}`
124
+ : "file";
125
+ const indent = " ".repeat(4);
126
+ const confidence = `${finding.lowConfidence ? " (low confidence)" : ""} [${VERIFICATION[finding.verification ?? "unchecked"]}]`;
127
+ const lines = [
128
+ ` ${finding.severity.padEnd(10)} ${location.padEnd(9)} ${finding.title}${confidence} #${finding.fingerprint.slice(0, 8)}`,
129
+ ];
130
+ lines.push(...indented(finding.body, indent));
131
+ if (finding.suggestion)
132
+ lines.push(...indented(`Suggestion: ${finding.suggestion}`, indent));
133
+ return lines;
134
+ }
135
+ function indented(text, indent) {
136
+ return text.split("\n").map((line) => `${indent}${line}`);
137
+ }
138
+ function groupByFile(findings) {
139
+ const groups = new Map();
140
+ for (const finding of [...findings].sort((a, b) => a.file.localeCompare(b.file))) {
141
+ const list = groups.get(finding.file) ?? [];
142
+ list.push(finding);
143
+ groups.set(finding.file, list);
144
+ }
145
+ return groups;
146
+ }
147
+ function summaryLine(report) {
148
+ const counts = SEVERITIES.map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`);
149
+ const { inputTokens, cachedTokens, outputTokens, reasoningTokens, costUsd } = report.usage;
150
+ return `${report.findings.length} finding(s) (${counts.join(", ")}) · tokens: ${inputTokens} in (${cachedTokens} cached), ${outputTokens} out, ${reasoningTokens} reasoning · $${costUsd.toFixed(4)}`;
151
+ }
152
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,15 @@
1
+ import type { OcraPlugin, PluginRegistry, VcsAdapter } from "@open-cr-agent/core";
2
+ import type { PullRequestTarget, ReviewArgs } from "./args.js";
3
+ import { type CliConfig } from "./config.js";
4
+ export interface ReviewTarget {
5
+ config: CliConfig;
6
+ plugins: OcraPlugin[];
7
+ createVcs(registry: PluginRegistry): VcsAdapter;
8
+ readTrusted?: (path: string) => Promise<string | undefined>;
9
+ publish: boolean;
10
+ }
11
+ type Env = Readonly<Record<string, string | undefined>>;
12
+ export declare function localTarget(args: ReviewArgs, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch): Promise<ReviewTarget>;
13
+ export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
14
+ export {};
15
+ //# sourceMappingURL=target.d.ts.map
@@ -0,0 +1,89 @@
1
+ import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github";
2
+ import { ensureCommits, filesChangedSince, LocalGitAdapter } from "@open-cr-agent/vcs-local";
3
+ import { ConfigError, loadConfig } from "./config.js";
4
+ import { loadExternalPlugins } from "./plugins.js";
5
+ export async function localTarget(args, cwd, root, env, warn, fetchImpl) {
6
+ const config = await loadConfig(root, env, {
7
+ repository: !args.ignoreRepoConfig,
8
+ warn,
9
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
10
+ });
11
+ return {
12
+ config,
13
+ plugins: await loadExternalPlugins(config.plugins, root),
14
+ createVcs: (registry) => registry.createVcs("local", { cwd, target: args.target }),
15
+ ...(args.ignoreRepoConfig ? { readTrusted: untrustedTreeReader(args, cwd) } : {}),
16
+ publish: false,
17
+ };
18
+ }
19
+ // --no-repo-config is for code you do not trust, so the files that steer a
20
+ // review (.ocra/memory.json can silence findings, rules and AGENTS.md shape
21
+ // the prompts) are not taken from it either: from the base of a range, which
22
+ // you chose, and otherwise not at all.
23
+ function untrustedTreeReader(args, cwd) {
24
+ if (args.target.mode !== "range")
25
+ return async () => undefined;
26
+ const base = new LocalGitAdapter({ cwd, target: { mode: "commit", commit: args.target.from } });
27
+ return (path) => base.readFile(path);
28
+ }
29
+ // The pull request's own files are untrusted: configuration, guidelines and
30
+ // rules come from its base commit, and repository plugins never load.
31
+ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
32
+ const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
33
+ if (!token)
34
+ throw new ConfigError("--pr needs a GitHub token in GITHUB_TOKEN or GH_TOKEN");
35
+ const [owner = "", repo = ""] = (pr.repo ?? (await repositoryName(root, env))).split("/");
36
+ const apiOptions = { token, ...(env.GITHUB_API_URL ? { baseUrl: env.GITHUB_API_URL } : {}) };
37
+ const api = new GitHubApi({ owner, repo }, fetchImpl ? { ...apiOptions, fetch: fetchImpl } : apiOptions);
38
+ const pull = await api.getPullRequest(pr.number);
39
+ await ensureCommits(root, [pull.base.sha, pull.head.sha], [`pull/${pr.number}/head`]);
40
+ const base = new LocalGitAdapter({ cwd, target: { mode: "commit", commit: pull.base.sha } });
41
+ const readTrusted = (path) => base.readFile(path);
42
+ const config = await loadConfig(root, env, {
43
+ repository: !ignoreRepoConfig,
44
+ read: readTrusted,
45
+ warn,
46
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
47
+ });
48
+ if (config.plugins.length > 0) {
49
+ warn("plugins in .ocra/config.json are not loaded for pull requests");
50
+ }
51
+ const code = new LocalGitAdapter({
52
+ cwd,
53
+ target: { mode: "range", from: pull.base.sha, to: pull.head.sha },
54
+ });
55
+ return {
56
+ config,
57
+ plugins: [],
58
+ createVcs: (registry) => registry.createVcs("github", {
59
+ owner,
60
+ repo,
61
+ number: pr.number,
62
+ token,
63
+ ...(env.GITHUB_API_URL ? { apiUrl: env.GITHUB_API_URL } : {}),
64
+ botLogin: config.github.botLogin ?? DEFAULT_BOT_LOGIN,
65
+ requestChanges: config.github.requestChanges ?? false,
66
+ code,
67
+ snapshot: pull,
68
+ history: {
69
+ filesChangedSince: (from, to) => filesChangedSince(root, from, to),
70
+ },
71
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
72
+ }),
73
+ readTrusted,
74
+ publish: pr.publish,
75
+ };
76
+ }
77
+ async function repositoryName(root, env) {
78
+ if (env.GITHUB_REPOSITORY)
79
+ return env.GITHUB_REPOSITORY;
80
+ const url = await new LocalGitAdapter({ cwd: root, target: { mode: "workspace" } })
81
+ .remoteUrl("origin")
82
+ .catch(() => undefined);
83
+ const match = url && /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
84
+ if (!match?.[1]) {
85
+ throw new ConfigError("Cannot tell which GitHub repository this is; pass --repo owner/name");
86
+ }
87
+ return match[1];
88
+ }
89
+ //# sourceMappingURL=target.js.map
@@ -0,0 +1,2 @@
1
+ export declare function forTerminal(text: string): string;
2
+ //# sourceMappingURL=terminal.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { isUnsafeCodePoint } from "@open-cr-agent/core";
2
+ // Findings, commit messages and errors can carry text a diff planted; none of
3
+ // it may drive the user's terminal. C0 and C1 controls (ANSI and OSC escapes
4
+ // start with ESC or a C1 code) except tab and newline, carriage returns, and
5
+ // bidirectional overrides ("Trojan Source") become U+FFFD.
6
+ export function forTerminal(text) {
7
+ let out = "";
8
+ for (const char of text)
9
+ out += isUnsafeCodePoint(char.codePointAt(0) ?? 0) ? "�" : char;
10
+ return out;
11
+ }
12
+ //# sourceMappingURL=terminal.js.map
package/dist/run.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type ReviewDeps } from "./review/command.js";
2
+ import type { Output } from "./review/progress.js";
3
+ export declare const VERSION: string;
4
+ export declare function defaultDeps(): ReviewDeps;
5
+ export declare function run(argv: string[], out: Output, err: Output, deps?: ReviewDeps): Promise<number>;
6
+ //# sourceMappingURL=run.d.ts.map
package/dist/run.js ADDED
@@ -0,0 +1,101 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { parseArgs } from "node:util";
4
+ import { errorMessage } from "@open-cr-agent/core";
5
+ import { memoryCommand } from "./memory.js";
6
+ import { parseReviewArgs, REVIEW_USAGE, UsageError } from "./review/args.js";
7
+ import { BUILTIN_PLUGINS, EXIT, reviewCommand } from "./review/command.js";
8
+ import { forTerminal } from "./review/terminal.js";
9
+ // src/ and dist/ both sit one level below the package root.
10
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
11
+ const USAGE = `Usage: ocra <command> [options]
12
+
13
+ Commands:
14
+ review Review code changes (run "ocra review --help" for options)
15
+ memory Remember findings the team accepts (run "ocra memory --help")
16
+
17
+ Options:
18
+ -h, --help Show help
19
+ -v, --version Show version
20
+ `;
21
+ export function defaultDeps() {
22
+ return {
23
+ cwd: process.cwd(),
24
+ env: process.env,
25
+ builtinPlugins: BUILTIN_PLUGINS,
26
+ writeFile: (path, content) => writeFile(path, content, "utf8"),
27
+ now: Date.now,
28
+ heartbeatMs: 30_000,
29
+ onInterrupt(handler) {
30
+ let signals = 0;
31
+ const listener = () => {
32
+ signals += 1;
33
+ if (signals > 1)
34
+ process.exit(EXIT.interrupted);
35
+ handler();
36
+ };
37
+ process.on("SIGINT", listener);
38
+ process.on("SIGTERM", listener);
39
+ return () => {
40
+ process.off("SIGINT", listener);
41
+ process.off("SIGTERM", listener);
42
+ };
43
+ },
44
+ };
45
+ }
46
+ export async function run(argv, out, err, deps = defaultDeps()) {
47
+ const [command, ...rest] = argv;
48
+ if (command === "review")
49
+ return review(rest, out, err, deps);
50
+ if (command === "memory") {
51
+ try {
52
+ return await memoryCommand(rest, out, deps.cwd);
53
+ }
54
+ catch (error) {
55
+ err.write(`ocra: ${forTerminal(errorMessage(error))}\n`);
56
+ return EXIT.error;
57
+ }
58
+ }
59
+ if (command !== undefined && !command.startsWith("-")) {
60
+ err.write(`Unknown command: ${command}\n\n${USAGE}`);
61
+ return EXIT.error;
62
+ }
63
+ let values;
64
+ try {
65
+ values = parseArgs({
66
+ args: argv,
67
+ strict: true,
68
+ options: { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" } },
69
+ }).values;
70
+ }
71
+ catch (error) {
72
+ err.write(`${errorMessage(error)}\n\n${USAGE}`);
73
+ return EXIT.error;
74
+ }
75
+ if (values.version) {
76
+ out.write(`${VERSION}\n`);
77
+ return EXIT.ok;
78
+ }
79
+ out.write(USAGE);
80
+ return EXIT.ok;
81
+ }
82
+ async function review(argv, out, err, deps) {
83
+ try {
84
+ const args = parseReviewArgs(argv);
85
+ if (args === "help") {
86
+ out.write(REVIEW_USAGE);
87
+ return EXIT.ok;
88
+ }
89
+ return await reviewCommand(args, { out, err }, deps);
90
+ }
91
+ catch (error) {
92
+ if (error instanceof UsageError) {
93
+ err.write(`${error.message}\n\n${REVIEW_USAGE}`);
94
+ }
95
+ else {
96
+ err.write(`ocra: ${forTerminal(errorMessage(error))}\n`);
97
+ }
98
+ return EXIT.error;
99
+ }
100
+ }
101
+ //# sourceMappingURL=run.js.map
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@open-cr-agent/cli",
3
+ "version": "0.1.0",
4
+ "description": "ocra: open-source multi-agent code review for local changes and GitHub pull requests",
5
+ "keywords": [
6
+ "code-review",
7
+ "cli",
8
+ "llm",
9
+ "agents",
10
+ "github-action"
11
+ ],
12
+ "license": "Apache-2.0",
13
+ "homepage": "https://ocra.majincheng.com",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/jma49/Open-CR-Agent.git",
17
+ "directory": "packages/cli"
18
+ },
19
+ "bugs": "https://github.com/jma49/Open-CR-Agent/issues",
20
+ "engines": {
21
+ "node": ">=22.19"
22
+ },
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "bin": {
31
+ "ocra": "./dist/main.js"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "!dist/**/*.map"
36
+ ],
37
+ "dependencies": {
38
+ "@open-cr-agent/core": "0.1.0",
39
+ "@open-cr-agent/runtime-opencode": "0.1.0",
40
+ "@open-cr-agent/vcs-github": "0.1.0",
41
+ "@open-cr-agent/vcs-local": "0.1.0",
42
+ "zod": "^4.6.5"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }