@open-cr-agent/cli 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `ocra` is the command line of [Open-CR-Agent](https://github.com/jma49/Open-CR-Agent), an open-source multi-agent code reviewer for local changes and GitHub pull requests. Deterministic code selects and bundles the files, matches rules and anchors comments; LLM reviewers make the judgment calls, and a verification pass checks what they report.
4
4
 
5
- This is an early 0.x release: options and output may change between minor versions. What the reviews find and miss is measured on the [quality page](https://ocra.majincheng.com/en/docs/quality).
5
+ This is an early 0.x release: options and output may change between minor versions. What the reviews find and miss is measured on the [quality page](https://ocracloud.com/en/docs/quality).
6
6
 
7
7
  ## Install
8
8
 
@@ -34,8 +34,8 @@ Pull requests can also be reviewed by the GitHub Action: inline comments, one su
34
34
 
35
35
  ## Documentation
36
36
 
37
- - [Quickstart](https://ocra.majincheng.com/en/docs/quickstart) and the manual, in [English](https://ocra.majincheng.com/en/docs) and [中文](https://ocra.majincheng.com/zh/docs)
38
- - [GitHub pull requests](https://ocra.majincheng.com/en/docs/github)
37
+ - [Quickstart](https://ocracloud.com/en/docs/quickstart) and the manual, in [English](https://ocracloud.com/en/docs) and [中文](https://ocracloud.com/zh/docs)
38
+ - [GitHub pull requests](https://ocracloud.com/en/docs/github)
39
39
  - [Changelog](https://github.com/jma49/Open-CR-Agent/blob/main/CHANGELOG.md) and [issues](https://github.com/jma49/Open-CR-Agent/issues)
40
40
 
41
41
  ## License
@@ -0,0 +1,32 @@
1
+ import type { Output } from "./review/progress.js";
2
+ /** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
3
+ export declare const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
4
+ export declare const LOGIN_USAGE: string;
5
+ export type Credentials = {
6
+ server: string;
7
+ login: string;
8
+ access_token: string;
9
+ refresh_token: string;
10
+ /** Epoch milliseconds when the access token expires. */
11
+ expires_at: number;
12
+ };
13
+ export type CloudDeps = {
14
+ env: Readonly<Record<string, string | undefined>>;
15
+ fetch: typeof fetch;
16
+ now: () => number;
17
+ sleep: (ms: number) => Promise<void>;
18
+ openBrowser: (url: string) => void;
19
+ credentialsPath: string;
20
+ clientName: string;
21
+ };
22
+ export declare function defaultCloudDeps(env?: CloudDeps["env"]): CloudDeps;
23
+ export declare function credentialsPath(env: CloudDeps["env"]): string;
24
+ export declare function readCredentials(path: string): Promise<Credentials | undefined>;
25
+ /**
26
+ * The saved session with a live access token, refreshed (and saved) when
27
+ * it has expired or is about to; undefined when not signed in or the
28
+ * session was revoked.
29
+ */
30
+ export declare function cloudSession(deps: CloudDeps, minValidityMs?: number): Promise<Credentials | undefined>;
31
+ export declare function cloudCommand(command: "login" | "logout" | "whoami", argv: string[], out: Output, err: Output, deps: CloudDeps): Promise<number>;
32
+ //# sourceMappingURL=cloud.d.ts.map
package/dist/cloud.js ADDED
@@ -0,0 +1,262 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { errorMessage } from "@open-cr-agent/core/internal";
7
+ import { UsageError } from "./review/args.js";
8
+ import { forTerminal } from "./review/terminal.js";
9
+ import { VERSION } from "./version.js";
10
+ // ocra Cloud sign-in (ADR-0024): `ocra login` runs the OAuth device flow
11
+ // (RFC 8628) against ocra Cloud, `ocra whoami` shows the account, and
12
+ // `ocra logout` ends the session. Nothing here runs unless one of these
13
+ // commands is used: a self-hosted user never sees a login prompt.
14
+ /** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
15
+ export const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
16
+ export const LOGIN_USAGE = `Usage: ocra login [--no-browser]
17
+ ocra logout
18
+ ocra whoami
19
+
20
+ Sign in to ocra Cloud with GitHub, so reviews can use the model keys stored
21
+ there. ocra login shows a code, opens the browser to confirm it, and saves the
22
+ session to ${credentialsHint()} (readable only by you).
23
+
24
+ Environment:
25
+ OCRA_CLOUD=off Disable ocra Cloud: these commands refuse to run
26
+ OCRA_CLOUD_URL Another ocra Cloud server (default ${DEFAULT_CLOUD_URL})
27
+ `;
28
+ export function defaultCloudDeps(env = process.env) {
29
+ return {
30
+ env,
31
+ fetch: globalThis.fetch,
32
+ now: Date.now,
33
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
34
+ openBrowser,
35
+ credentialsPath: credentialsPath(env),
36
+ clientName: `${hostname()} (ocra ${VERSION})`,
37
+ };
38
+ }
39
+ function credentialsHint() {
40
+ return process.platform === "win32"
41
+ ? "%APPDATA%\\ocra\\credentials.json"
42
+ : "~/.config/ocra/credentials.json";
43
+ }
44
+ export function credentialsPath(env) {
45
+ const base = process.platform === "win32"
46
+ ? (env.APPDATA ?? join(homedir(), "AppData", "Roaming"))
47
+ : (env.XDG_CONFIG_HOME ?? join(homedir(), ".config"));
48
+ return join(base, "ocra", "credentials.json");
49
+ }
50
+ function cloudUrl(env) {
51
+ const named = env.OCRA_CLOUD_URL || DEFAULT_CLOUD_URL;
52
+ let url;
53
+ try {
54
+ url = new URL(named);
55
+ }
56
+ catch {
57
+ throw new UsageError(`OCRA_CLOUD_URL is not a URL: ${named}`);
58
+ }
59
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
60
+ throw new UsageError(`OCRA_CLOUD_URL must use https: ${url}`);
61
+ }
62
+ return url.origin;
63
+ }
64
+ function openBrowser(url) {
65
+ const [cmd, args] = process.platform === "darwin"
66
+ ? ["open", [url]]
67
+ : process.platform === "win32"
68
+ ? ["cmd", ["/c", "start", "", url]]
69
+ : ["xdg-open", [url]];
70
+ try {
71
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
72
+ child.on("error", () => { });
73
+ child.unref();
74
+ }
75
+ catch {
76
+ // The URL is printed as well; a missing opener is not an error.
77
+ }
78
+ }
79
+ export async function readCredentials(path) {
80
+ let text;
81
+ try {
82
+ text = await readFile(path, "utf8");
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ const c = JSON.parse(text);
88
+ if (!c.server || !c.access_token || !c.refresh_token || typeof c.expires_at !== "number") {
89
+ return undefined;
90
+ }
91
+ return c;
92
+ }
93
+ async function writeCredentials(path, c) {
94
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
95
+ await writeFile(path, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
96
+ // writeFile keeps the mode of a file that already exists.
97
+ await chmod(path, 0o600);
98
+ }
99
+ async function postJson(deps, url, body, token) {
100
+ const res = await deps.fetch(url, {
101
+ method: "POST",
102
+ headers: {
103
+ "content-type": "application/json",
104
+ "user-agent": `ocra/${VERSION}`,
105
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
106
+ },
107
+ body: JSON.stringify(body),
108
+ signal: AbortSignal.timeout(30_000),
109
+ });
110
+ return {
111
+ status: res.status,
112
+ body: (await res.json().catch(() => ({}))),
113
+ };
114
+ }
115
+ async function fetchMe(deps, server, token) {
116
+ const res = await deps.fetch(`${server}/api/me`, {
117
+ headers: { authorization: `Bearer ${token}`, "user-agent": `ocra/${VERSION}` },
118
+ signal: AbortSignal.timeout(30_000),
119
+ });
120
+ if (res.status === 401)
121
+ return undefined;
122
+ if (!res.ok)
123
+ throw new Error(`ocra Cloud answered ${res.status}`);
124
+ return (await res.json());
125
+ }
126
+ /**
127
+ * The saved session with a live access token, refreshed (and saved) when
128
+ * it has expired or is about to; undefined when not signed in or the
129
+ * session was revoked.
130
+ */
131
+ export async function cloudSession(deps, minValidityMs = 60_000) {
132
+ const saved = await readCredentials(deps.credentialsPath);
133
+ if (!saved)
134
+ return undefined;
135
+ if (saved.expires_at - deps.now() > minValidityMs)
136
+ return saved;
137
+ const { status, body } = await postJson(deps, `${saved.server}/api/device/refresh`, {
138
+ refresh_token: saved.refresh_token,
139
+ });
140
+ const t = body;
141
+ if (status !== 200 || !t.access_token || !t.refresh_token || !t.expires_in)
142
+ return undefined;
143
+ const next = {
144
+ ...saved,
145
+ access_token: t.access_token,
146
+ refresh_token: t.refresh_token,
147
+ expires_at: deps.now() + t.expires_in * 1000,
148
+ };
149
+ await writeCredentials(deps.credentialsPath, next);
150
+ return next;
151
+ }
152
+ export async function cloudCommand(command, argv, out, err, deps) {
153
+ const { values } = parseArgs({
154
+ args: argv,
155
+ strict: true,
156
+ options: { "no-browser": { type: "boolean" }, help: { type: "boolean", short: "h" } },
157
+ });
158
+ if (values.help) {
159
+ out.write(LOGIN_USAGE);
160
+ return 0;
161
+ }
162
+ if (deps.env.OCRA_CLOUD === "off") {
163
+ err.write("ocra Cloud is off (OCRA_CLOUD=off).\n");
164
+ return 2;
165
+ }
166
+ if (command === "whoami")
167
+ return whoami(out, err, deps);
168
+ if (command === "logout")
169
+ return logout(out, deps);
170
+ return login(out, err, deps, !values["no-browser"]);
171
+ }
172
+ async function whoami(out, err, deps) {
173
+ const session = await cloudSession(deps);
174
+ if (!session) {
175
+ err.write("Not signed in. Run ocra login.\n");
176
+ return 1;
177
+ }
178
+ const me = await fetchMe(deps, session.server, session.access_token);
179
+ if (!me) {
180
+ err.write("The saved session was revoked. Run ocra login.\n");
181
+ return 1;
182
+ }
183
+ out.write(forTerminal(`${me.login} on ${session.server}\n`));
184
+ return 0;
185
+ }
186
+ async function logout(out, deps) {
187
+ const saved = await readCredentials(deps.credentialsPath);
188
+ if (saved) {
189
+ // Ends the session on the server too; the local file goes either way.
190
+ const session = await cloudSession(deps).catch(() => undefined);
191
+ if (session) {
192
+ await postJson(deps, `${session.server}/api/auth/logout`, {}, session.access_token).catch(() => { });
193
+ }
194
+ }
195
+ await rm(deps.credentialsPath, { force: true });
196
+ out.write(saved ? "Signed out.\n" : "Not signed in.\n");
197
+ return 0;
198
+ }
199
+ async function login(out, err, deps, browser) {
200
+ const server = cloudUrl(deps.env);
201
+ const start = await postJson(deps, `${server}/api/device/code`, { client_name: deps.clientName });
202
+ const code = start.body;
203
+ if (start.status !== 200 ||
204
+ !code.device_code ||
205
+ !code.user_code ||
206
+ !code.verification_uri_complete) {
207
+ err.write(`ocra Cloud did not start a login (HTTP ${start.status}). Try again in a minute.\n`);
208
+ return 2;
209
+ }
210
+ out.write(forTerminal(`Confirm this code in your browser: ${code.user_code}\n` +
211
+ ` ${code.verification_uri_complete}\n` +
212
+ `(or open ${code.verification_uri} and type it)\n\nWaiting for approval...\n`));
213
+ if (browser)
214
+ deps.openBrowser(code.verification_uri_complete);
215
+ let interval = (code.interval ?? 5) * 1000;
216
+ const deadline = deps.now() + (code.expires_in ?? 600) * 1000;
217
+ while (deps.now() < deadline) {
218
+ await deps.sleep(interval);
219
+ let answer;
220
+ try {
221
+ answer = await postJson(deps, `${server}/api/device/token`, {
222
+ device_code: code.device_code,
223
+ });
224
+ }
225
+ catch (error) {
226
+ // A dropped connection is retried on the next tick.
227
+ err.write(forTerminal(`(${errorMessage(error)}; retrying)\n`));
228
+ continue;
229
+ }
230
+ const t = answer.body;
231
+ if (answer.status === 200 && t.access_token && t.refresh_token && t.expires_in) {
232
+ const me = await fetchMe(deps, server, t.access_token);
233
+ const credentials = {
234
+ server,
235
+ login: me?.login ?? "",
236
+ access_token: t.access_token,
237
+ refresh_token: t.refresh_token,
238
+ expires_at: deps.now() + t.expires_in * 1000,
239
+ };
240
+ await writeCredentials(deps.credentialsPath, credentials);
241
+ out.write(forTerminal(`Signed in as ${credentials.login}.\n`));
242
+ return 0;
243
+ }
244
+ if (t.error === "authorization_pending")
245
+ continue;
246
+ if (t.error === "slow_down") {
247
+ interval = typeof t.interval === "number" ? t.interval * 1000 : interval + 5000;
248
+ continue;
249
+ }
250
+ if (t.error === "access_denied") {
251
+ err.write("Login denied in the browser.\n");
252
+ return 1;
253
+ }
254
+ if (t.error === "expired_token")
255
+ break;
256
+ err.write(forTerminal(`Login failed: ${t.error ?? `HTTP ${answer.status}`}\n`));
257
+ return 2;
258
+ }
259
+ err.write("The code expired before it was confirmed. Run ocra login again.\n");
260
+ return 1;
261
+ }
262
+ //# sourceMappingURL=cloud.js.map
@@ -25,12 +25,13 @@ export interface ReviewArgs {
25
25
  plan?: true;
26
26
  importSarif?: string[];
27
27
  configFile?: string;
28
+ noUpload?: true;
28
29
  temperature?: number;
29
30
  seed?: number;
30
31
  }
31
32
  export declare class UsageError extends OcraError {
32
33
  constructor(message: string);
33
34
  }
34
- export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n -h, --help Show help\n";
35
+ export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n --no-upload When signed in to ocra Cloud, do not send this review's\n counts (verdict, numbers of findings, files, tokens)\n -h, --help Show help\n";
35
36
  export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
36
37
  //# sourceMappingURL=args.d.ts.map
@@ -35,6 +35,8 @@ Options:
35
35
  .ocra/config.json; it applies with --no-repo-config too
36
36
  --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
37
37
  code); models come from OCRA_MODEL_* variables or --config
38
+ --no-upload When signed in to ocra Cloud, do not send this review's
39
+ counts (verdict, numbers of findings, files, tokens)
38
40
  -h, --help Show help
39
41
  `;
40
42
  export function parseReviewArgs(argv) {
@@ -70,6 +72,8 @@ export function parseReviewArgs(argv) {
70
72
  args.output = values.output;
71
73
  if (values["no-repo-config"])
72
74
  args.ignoreRepoConfig = true;
75
+ if (values["no-upload"])
76
+ args.noUpload = true;
73
77
  if (values.ultra)
74
78
  args.ultra = true;
75
79
  if (values.full) {
@@ -137,6 +141,7 @@ function parse(argv) {
137
141
  format: { type: "string" },
138
142
  output: { type: "string" },
139
143
  "no-repo-config": { type: "boolean" },
144
+ "no-upload": { type: "boolean" },
140
145
  config: { type: "string" },
141
146
  reviewers: { type: "string" },
142
147
  "max-cost-usd": { type: "string" },
@@ -0,0 +1,12 @@
1
+ import type { CustomProvider, ModelChains } from "@open-cr-agent/core";
2
+ import { type CloudDeps } from "../cloud.js";
3
+ export declare const CLOUD_PREFIX = "ocra-";
4
+ /** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
5
+ export declare const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
6
+ type Env = Readonly<Record<string, string | undefined>>;
7
+ export declare function withCloudProviders(models: ModelChains, providers: Readonly<Record<string, CustomProvider>>, env: Env, deps: CloudDeps | undefined, warn: (message: string) => void): Promise<{
8
+ providers: Record<string, CustomProvider>;
9
+ env: Env;
10
+ }>;
11
+ export {};
12
+ //# sourceMappingURL=cloud-providers.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { OcraError } from "@open-cr-agent/core";
2
+ import { cloudSession } from "../cloud.js";
3
+ // Models named ocra-<provider>/<model> go through the ocra Cloud gateway with
4
+ // the key stored there for <provider> (ADR-0024). Each such provider becomes
5
+ // an OpenAI-compatible endpoint declared for this run, authorized by the
6
+ // signed-in session's access token. Without such a model nothing here reads
7
+ // the session or touches the network.
8
+ export const CLOUD_PREFIX = "ocra-";
9
+ /** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
10
+ export const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
11
+ // A review may run for half an hour; start it with a token that outlives it.
12
+ const MIN_TOKEN_MS = 35 * 60_000;
13
+ export async function withCloudProviders(models, providers, env, deps, warn) {
14
+ const wanted = new Map();
15
+ for (const model of Object.values(models).flat()) {
16
+ const slash = model.indexOf("/");
17
+ const id = model.slice(0, slash);
18
+ if (slash < 0 || !id.startsWith(CLOUD_PREFIX) || providers[id])
19
+ continue;
20
+ const set = wanted.get(id) ?? new Set();
21
+ set.add(model.slice(slash + 1));
22
+ wanted.set(id, set);
23
+ }
24
+ if (wanted.size === 0)
25
+ return { providers: { ...providers }, env };
26
+ const names = [...wanted.keys()].join(", ");
27
+ if (env.OCRA_CLOUD === "off") {
28
+ throw new OcraError("CONFIG_INVALID", `Models name ocra Cloud (${names}) but OCRA_CLOUD=off`);
29
+ }
30
+ const session = deps && (await cloudSession(deps, MIN_TOKEN_MS));
31
+ if (!session) {
32
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", `Models name ocra Cloud (${names}): sign in with ocra login`);
33
+ }
34
+ const res = await deps.fetch(`${session.server}/api/providers`, {
35
+ signal: AbortSignal.timeout(30_000),
36
+ });
37
+ if (!res.ok) {
38
+ throw new OcraError("RUNTIME_START_FAILED", `ocra Cloud answered ${res.status} for its providers`);
39
+ }
40
+ const listed = (await res.json()).providers ?? [];
41
+ const declared = { ...providers };
42
+ for (const [id, modelIds] of wanted) {
43
+ const name = id.slice(CLOUD_PREFIX.length);
44
+ const chat = listed
45
+ .find((p) => p.name === name)
46
+ ?.paths.find((path) => path.endsWith("/chat/completions"));
47
+ if (!chat) {
48
+ throw new OcraError("CONFIG_INVALID", `ocra Cloud has no OpenAI-compatible chat endpoint for "${name}" (named by ${id})`);
49
+ }
50
+ declared[id] = {
51
+ baseUrl: `${session.server}/api/gateway/${name}${chat.slice(0, -"/chat/completions".length)}`,
52
+ apiKeyEnv: CLOUD_TOKEN_ENV,
53
+ models: Object.fromEntries([...modelIds].map((m) => [m, { input: 0, output: 0 }])),
54
+ };
55
+ }
56
+ warn(`${names}: models through ocra Cloud are not priced, so reported cost and --max-cost-usd do not count them`);
57
+ return { providers: declared, env: { ...env, [CLOUD_TOKEN_ENV]: session.access_token } };
58
+ }
59
+ //# sourceMappingURL=cloud-providers.js.map
@@ -0,0 +1,40 @@
1
+ import { type ModelChains, type ReviewReport } from "@open-cr-agent/core";
2
+ import { type CloudDeps } from "../cloud.js";
3
+ export type ReviewSource = "local" | "github" | "gitlab";
4
+ export type ReviewUpload = {
5
+ runId: string;
6
+ repoHash: string;
7
+ source: ReviewSource;
8
+ tier: string;
9
+ verdict: string;
10
+ complete: boolean;
11
+ findings: {
12
+ critical: number;
13
+ warning: number;
14
+ suggestion: number;
15
+ };
16
+ files: {
17
+ reviewed: number;
18
+ notReviewed: number;
19
+ };
20
+ tasks: {
21
+ completed: number;
22
+ failed: number;
23
+ };
24
+ usage: {
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ costUsd: number;
28
+ };
29
+ durationMs: number;
30
+ ocraVersion: string;
31
+ };
32
+ export declare function uploadOf(report: ReviewReport, source: ReviewSource, repoHash: string, durationMs: number): ReviewUpload;
33
+ export declare function repoHash(root: string, credentialsPath: string): Promise<string>;
34
+ /** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
35
+ export declare function cloudEnabled(deps: CloudDeps): Promise<boolean>;
36
+ /** Sends the counts; a failure is a warning, never a failed review. */
37
+ export declare function uploadReview(upload: ReviewUpload, deps: CloudDeps, warn: (message: string) => void): Promise<boolean>;
38
+ /** The default models chosen on the web, for a review that configures none. */
39
+ export declare function defaultModels(deps: CloudDeps): Promise<ModelChains>;
40
+ //# sourceMappingURL=cloud-upload.d.ts.map
@@ -0,0 +1,135 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { coverageGaps } from "@open-cr-agent/core";
7
+ import { cloudSession, readCredentials } from "../cloud.js";
8
+ import { VERSION } from "../version.js";
9
+ // After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
10
+ // the verdict, how many findings of each severity, files and tasks, tokens
11
+ // and time. Never a path, a title, a finding's text or code. The repository
12
+ // is a hash salted with a value that stays on this machine, so the server
13
+ // can group reviews of one repository without learning which it is.
14
+ const run = promisify(execFile);
15
+ export function uploadOf(report, source, repoHash, durationMs) {
16
+ const severity = (s) => report.findings.filter((f) => f.severity === s).length;
17
+ const gaps = coverageGaps(report);
18
+ return {
19
+ runId: report.runId,
20
+ repoHash,
21
+ source,
22
+ tier: report.tier,
23
+ verdict: report.verdict,
24
+ complete: gaps.notReviewed === 0,
25
+ findings: {
26
+ critical: severity("critical"),
27
+ warning: severity("warning"),
28
+ suggestion: severity("suggestion"),
29
+ },
30
+ files: {
31
+ reviewed: report.coverage.filter((c) => c.status === "reviewed").length,
32
+ notReviewed: gaps.notReviewed,
33
+ },
34
+ tasks: {
35
+ completed: report.tasks.filter((t) => t.status === "completed").length,
36
+ failed: report.tasks.filter((t) => t.status === "failed").length,
37
+ },
38
+ usage: {
39
+ inputTokens: report.usage.inputTokens,
40
+ outputTokens: report.usage.outputTokens,
41
+ costUsd: report.usage.costUsd ?? 0,
42
+ },
43
+ durationMs: Math.round(durationMs),
44
+ ocraVersion: VERSION,
45
+ };
46
+ }
47
+ /** The repository's origin without credentials, or its root path when it has none. */
48
+ async function repositoryId(root) {
49
+ try {
50
+ const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], {
51
+ timeout: 10_000,
52
+ });
53
+ const url = stdout.trim();
54
+ return url
55
+ .replace(/^[a-z+]+:\/\/[^@/]*@/i, (m) => m.slice(0, m.indexOf("//") + 2))
56
+ .replace(/\.git$/, "")
57
+ .toLowerCase();
58
+ }
59
+ catch {
60
+ return root;
61
+ }
62
+ }
63
+ /** A random salt kept beside the credentials, made on first use. */
64
+ async function salt(credentialsPath) {
65
+ const path = join(dirname(credentialsPath), "upload-salt");
66
+ try {
67
+ const existing = (await readFile(path, "utf8")).trim();
68
+ if (/^[0-9a-f]{64}$/.test(existing))
69
+ return existing;
70
+ }
71
+ catch { }
72
+ const fresh = randomBytes(32).toString("hex");
73
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
74
+ await writeFile(path, `${fresh}\n`, { mode: 0o600 });
75
+ return fresh;
76
+ }
77
+ export async function repoHash(root, credentialsPath) {
78
+ return createHash("sha256")
79
+ .update(`${await salt(credentialsPath)}:${await repositoryId(root)}`)
80
+ .digest("hex");
81
+ }
82
+ /** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
83
+ export async function cloudEnabled(deps) {
84
+ return (deps.env.OCRA_CLOUD !== "off" && (await readCredentials(deps.credentialsPath)) !== undefined);
85
+ }
86
+ /** Sends the counts; a failure is a warning, never a failed review. */
87
+ export async function uploadReview(upload, deps, warn) {
88
+ try {
89
+ const session = await cloudSession(deps);
90
+ if (!session)
91
+ return false;
92
+ const res = await deps.fetch(`${session.server}/api/reviews`, {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ authorization: `Bearer ${session.access_token}`,
97
+ "user-agent": `ocra/${VERSION}`,
98
+ },
99
+ body: JSON.stringify(upload),
100
+ signal: AbortSignal.timeout(15_000),
101
+ });
102
+ if (!res.ok) {
103
+ warn(`ocra Cloud did not take the review's counts (HTTP ${res.status})`);
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+ catch (error) {
109
+ warn(`could not send the review's counts to ocra Cloud: ${error instanceof Error ? error.name : "error"}`);
110
+ return false;
111
+ }
112
+ }
113
+ /** The default models chosen on the web, for a review that configures none. */
114
+ export async function defaultModels(deps) {
115
+ const session = await cloudSession(deps);
116
+ if (!session)
117
+ return {};
118
+ const res = await deps.fetch(`${session.server}/api/preferences/models`, {
119
+ headers: { authorization: `Bearer ${session.access_token}`, "user-agent": `ocra/${VERSION}` },
120
+ signal: AbortSignal.timeout(15_000),
121
+ });
122
+ if (!res.ok)
123
+ return {};
124
+ const body = (await res.json());
125
+ const chains = {};
126
+ for (const tier of ["top", "standard", "light"]) {
127
+ const list = body.models?.[tier];
128
+ if (Array.isArray(list) && list.every((m) => typeof m === "string" && m.startsWith("ocra-"))) {
129
+ if (list.length > 0)
130
+ chains[tier] = list;
131
+ }
132
+ }
133
+ return chains;
134
+ }
135
+ //# sourceMappingURL=cloud-upload.js.map
@@ -1,4 +1,5 @@
1
1
  import { type OcraPlugin } from "@open-cr-agent/core";
2
+ import type { CloudDeps } from "../cloud.js";
2
3
  import type { ReviewArgs } from "./args.js";
3
4
  import { type Output } from "./progress.js";
4
5
  import type { RuntimeLoaders } from "./runtimes.js";
@@ -20,6 +21,7 @@ export interface ReviewDeps {
20
21
  now(): number;
21
22
  heartbeatMs: number;
22
23
  fetch?: typeof fetch;
24
+ cloud?: CloudDeps;
23
25
  onInterrupt?(handler: () => void): () => void;
24
26
  }
25
27
  export declare function reviewCommand(args: ReviewArgs, io: {
@@ -6,6 +6,8 @@ import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
6
6
  import { localGitPlugin } from "@open-cr-agent/vcs-local";
7
7
  import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
8
8
  import { VERSION } from "../version.js";
9
+ import { withCloudProviders } from "./cloud-providers.js";
10
+ import { cloudEnabled, defaultModels, repoHash, uploadOf, uploadReview } from "./cloud-upload.js";
9
11
  import { ConfigError } from "./config.js";
10
12
  import { renderPlan } from "./plan-render.js";
11
13
  import { ProgressPrinter } from "./progress.js";
@@ -60,6 +62,8 @@ export async function reviewCommand(args, io, deps) {
60
62
  vcs,
61
63
  reviewers: registry.reviewers,
62
64
  reviewerOverrides: overrides,
65
+ effort: config.effort,
66
+ roles: config.roles,
63
67
  rules: [...registry.rules, ...config.rules],
64
68
  selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
65
69
  ...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
@@ -75,10 +79,20 @@ export async function reviewCommand(args, io, deps) {
75
79
  }
76
80
  const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
77
81
  const sampling = requestedSampling(config, args);
82
+ const cloudDeps = deps.cloud;
83
+ const signedIn = cloudDeps !== undefined && (await cloudEnabled(cloudDeps));
84
+ let models = config.models;
85
+ if (cloudDeps && signedIn && Object.values(models).every((chain) => !chain?.length)) {
86
+ models = await defaultModels(cloudDeps);
87
+ if (Object.keys(models).length > 0) {
88
+ io.err.write("[ocra] No models configured: using your default models from ocra Cloud\n");
89
+ }
90
+ }
91
+ const cloud = await withCloudProviders(models, config.providers, deps.env, cloudDeps, warn);
78
92
  const runtime = registry.createRuntime(config.runtime, {
79
- models: config.models,
80
- env: deps.env,
81
- providers: config.providers,
93
+ models,
94
+ env: cloud.env,
95
+ providers: cloud.providers,
82
96
  ...(Object.keys(sampling).length > 0 ? { sampling } : {}),
83
97
  });
84
98
  const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
@@ -88,6 +102,7 @@ export async function reviewCommand(args, io, deps) {
88
102
  interrupt.abort();
89
103
  });
90
104
  let report;
105
+ const started = deps.now();
91
106
  try {
92
107
  report = await review({
93
108
  runId: session.id,
@@ -146,6 +161,13 @@ export async function reviewCommand(args, io, deps) {
146
161
  }
147
162
  io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
148
163
  }
164
+ if (signedIn && cloudDeps && !args.noUpload) {
165
+ const source = args.pullRequest ? "github" : args.mergeRequest ? "gitlab" : "local";
166
+ const upload = uploadOf(report, source, await repoHash(root, cloudDeps.credentialsPath), deps.now() - started);
167
+ if (await uploadReview(upload, cloudDeps, warn)) {
168
+ io.err.write("[ocra] Sent this review's counts to ocra Cloud (--no-upload to skip)\n");
169
+ }
170
+ }
149
171
  return exitCode(report, io.err);
150
172
  }
151
173
  // A runtime not built in comes from a plugin of the configuration.
@@ -156,6 +178,8 @@ async function builtinRuntime(runtimes, name) {
156
178
  function runOptions(config) {
157
179
  const options = {
158
180
  selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
181
+ effort: config.effort,
182
+ roles: config.roles,
159
183
  };
160
184
  if (config.concurrency !== undefined)
161
185
  options.concurrency = config.concurrency;
@@ -1,4 +1,4 @@
1
- import { type CustomProvider, type ModelChains, OcraError, type RepoRule } from "@open-cr-agent/core";
1
+ import { type CustomProvider, type ModelChains, OcraError, type RepoRule, type TierEfforts } from "@open-cr-agent/core";
2
2
  import { z } from "zod";
3
3
  export declare const CONFIG_PATH = ".ocra/config.json";
4
4
  declare const configSchema: z.ZodObject<{
@@ -8,6 +8,29 @@ declare const configSchema: z.ZodObject<{
8
8
  standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
9
9
  light: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
10
10
  }, z.core.$strict>>;
11
+ effort: z.ZodDefault<z.ZodObject<{
12
+ top: z.ZodOptional<z.ZodEnum<{
13
+ high: "high";
14
+ low: "low";
15
+ medium: "medium";
16
+ minimal: "minimal";
17
+ none: "none";
18
+ }>>;
19
+ standard: z.ZodOptional<z.ZodEnum<{
20
+ high: "high";
21
+ low: "low";
22
+ medium: "medium";
23
+ minimal: "minimal";
24
+ none: "none";
25
+ }>>;
26
+ light: z.ZodOptional<z.ZodEnum<{
27
+ high: "high";
28
+ low: "low";
29
+ medium: "medium";
30
+ minimal: "minimal";
31
+ none: "none";
32
+ }>>;
33
+ }, z.core.$strict>>;
11
34
  concurrency: z.ZodOptional<z.ZodNumber>;
12
35
  taskTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
13
36
  runTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
@@ -37,6 +60,26 @@ declare const configSchema: z.ZodObject<{
37
60
  lite: "lite";
38
61
  trivial: "trivial";
39
62
  }>>;
63
+ effort: z.ZodOptional<z.ZodEnum<{
64
+ high: "high";
65
+ low: "low";
66
+ medium: "medium";
67
+ minimal: "minimal";
68
+ none: "none";
69
+ }>>;
70
+ }, z.core.$strict>>>;
71
+ roles: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
72
+ helper: "helper";
73
+ judge: "judge";
74
+ verifier: "verifier";
75
+ }> & z.core.$partial, z.ZodObject<{
76
+ effort: z.ZodOptional<z.ZodEnum<{
77
+ high: "high";
78
+ low: "low";
79
+ medium: "medium";
80
+ minimal: "minimal";
81
+ none: "none";
82
+ }>>;
40
83
  }, z.core.$strict>>>;
41
84
  pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
42
85
  providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -48,13 +91,18 @@ declare const configSchema: z.ZodObject<{
48
91
  output: z.ZodNumber;
49
92
  cachedInput: z.ZodOptional<z.ZodNumber>;
50
93
  }, z.core.$strict>>;
94
+ effort: z.ZodOptional<z.ZodEnum<{
95
+ openai: "openai";
96
+ openrouter: "openrouter";
97
+ }>>;
51
98
  }, z.core.$strict>>>;
52
99
  extends: z.ZodOptional<z.ZodString>;
53
100
  }, z.core.$strict>;
54
101
  export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
55
102
  export declare function configJsonSchema(): Record<string, unknown>;
56
- export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
103
+ export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "effort" | "providers"> & {
57
104
  models: ModelChains;
105
+ effort: TierEfforts;
58
106
  providers: Record<string, CustomProvider>;
59
107
  rules: RepoRule[];
60
108
  };
@@ -1,10 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { OcraError, } from "@open-cr-agent/core";
4
- import { RISK_TIERS } from "@open-cr-agent/core/internal";
4
+ import { AGENT_ROLES, EFFORT_LEVELS, RISK_TIERS } from "@open-cr-agent/core/internal";
5
5
  import { z } from "zod";
6
6
  import { fetchRemoteConfig, mergeConfig } from "./remote-config.js";
7
7
  export const CONFIG_PATH = ".ocra/config.json";
8
+ // How much a model reasons before answering (ADR-0025); unset leaves the
9
+ // provider's default.
10
+ const effort = z.enum(EFFORT_LEVELS);
8
11
  const modelChain = z
9
12
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
10
13
  .transform((value) => (typeof value === "string" ? [value] : value));
@@ -40,6 +43,9 @@ const providerSchema = z
40
43
  models: z
41
44
  .record(z.string().regex(/^[\w./:@-]{1,200}$/), z.object({ input: price, output: price, cachedInput: price.optional() }).strict())
42
45
  .refine((models) => Object.keys(models).length > 0, "must list at least one model"),
46
+ // How the endpoint takes a reasoning effort: "openai" sends
47
+ // reasoning_effort (the default), "openrouter" sends reasoning.effort.
48
+ effort: z.enum(["openai", "openrouter"]).optional(),
43
49
  })
44
50
  .strict();
45
51
  // The runtimes ocra ships; a plugin may register another name, so the
@@ -53,6 +59,11 @@ const configSchema = z
53
59
  .partial()
54
60
  .strict()
55
61
  .default({}),
62
+ effort: z
63
+ .object({ top: effort, standard: effort, light: effort })
64
+ .partial()
65
+ .strict()
66
+ .default({}),
56
67
  concurrency: z.number().int().min(1).max(32).optional(),
57
68
  taskTimeoutMinutes: z.number().positive().optional(),
58
69
  runTimeoutMinutes: z.number().positive().optional(),
@@ -88,10 +99,14 @@ const configSchema = z
88
99
  .object({
89
100
  enabled: z.boolean(),
90
101
  minTier: z.enum(RISK_TIERS),
102
+ effort,
91
103
  })
92
104
  .partial()
93
105
  .strict())
94
106
  .default({}),
107
+ roles: z
108
+ .partialRecord(z.enum(AGENT_ROLES), z.object({ effort }).partial().strict())
109
+ .default({}),
95
110
  pluginSettings: z.record(z.string(), z.unknown()).default({}),
96
111
  providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
97
112
  extends: z.string().min(1).optional(),
@@ -116,6 +131,11 @@ const MODEL_ENV = {
116
131
  standard: "OCRA_MODEL_STANDARD",
117
132
  light: "OCRA_MODEL_LIGHT",
118
133
  };
134
+ const EFFORT_ENV = {
135
+ top: "OCRA_EFFORT_TOP",
136
+ standard: "OCRA_EFFORT_STANDARD",
137
+ light: "OCRA_EFFORT_LIGHT",
138
+ };
119
139
  export class ConfigError extends OcraError {
120
140
  constructor(message, options) {
121
141
  super("CONFIG_INVALID", message, options);
@@ -144,10 +164,29 @@ export async function loadConfig(root, env, options = { repository: true }) {
144
164
  if (chain.length > 0)
145
165
  models[tier] = chain;
146
166
  }
167
+ const efforts = {
168
+ ...parsed.effort,
169
+ };
170
+ for (const [tier, name] of Object.entries(EFFORT_ENV)) {
171
+ const value = env[name]?.trim();
172
+ if (!value)
173
+ continue;
174
+ const level = effort.safeParse(value);
175
+ if (!level.success) {
176
+ throw new ConfigError(`${name} must be one of ${EFFORT_LEVELS.join(", ")}`);
177
+ }
178
+ efforts[tier] = level.data;
179
+ }
147
180
  for (const model of unpriced(parsed.providers, models)) {
148
181
  options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
149
182
  }
150
- return { ...parsed, models, providers: toProviders(parsed.providers), rules };
183
+ return {
184
+ ...parsed,
185
+ models,
186
+ effort: efforts,
187
+ providers: toProviders(parsed.providers),
188
+ rules,
189
+ };
151
190
  }
152
191
  function toProviders(declared) {
153
192
  return Object.fromEntries(Object.entries(declared).map(([id, p]) => [
@@ -155,6 +194,7 @@ function toProviders(declared) {
155
194
  {
156
195
  baseUrl: p.baseUrl,
157
196
  ...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}),
197
+ ...(p.effort ? { effort: p.effort } : {}),
158
198
  models: Object.fromEntries(Object.entries(p.models).map(([model, price]) => [
159
199
  model,
160
200
  {
@@ -20,7 +20,7 @@ export function renderPlan(preview) {
20
20
  lines.push("", `Review tasks: ${preview.tasks.length}`);
21
21
  const width = Math.max(0, ...preview.tasks.map((t) => t.taskId.length));
22
22
  for (const t of preview.tasks) {
23
- lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`} ${t.bundle}`);
23
+ lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`}${t.effort === undefined ? "" : ` effort ${t.effort}`} ${t.bundle}`);
24
24
  }
25
25
  if (preview.skipped.length > 0) {
26
26
  lines.push("", "Skipped by the review matrix:");
@@ -12,11 +12,14 @@ export function requestedSampling(config, args) {
12
12
  // The effective configuration and the flags that change what is reviewed
13
13
  // and how, so two runs can tell they were set up alike. Keys are names of
14
14
  // environment variables, never their values; a password written into a
15
- // URL is dropped. Sampling is recorded on its own.
15
+ // URL is dropped. Sampling is recorded on its own. Effort is covered; left
16
+ // unset it adds nothing, so configurations that set none keep their hash.
16
17
  export function configHash(config, args) {
17
- const { sampling: _, providers, extends: shared, ...rest } = config;
18
+ const { sampling: _, providers, extends: shared, effort, roles, ...rest } = config;
18
19
  return stableHash({
19
20
  ...rest,
21
+ ...(Object.keys(effort).length > 0 ? { effort } : {}),
22
+ ...(Object.keys(roles).length > 0 ? { roles } : {}),
20
23
  ...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
21
24
  providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
22
25
  id,
@@ -6,6 +6,11 @@ export declare const remoteConfigSchema: z.ZodObject<{
6
6
  standard: "standard";
7
7
  top: "top";
8
8
  }>, z.ZodUnknown>>;
9
+ effort: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
10
+ light: "light";
11
+ standard: "standard";
12
+ top: "top";
13
+ }> & z.core.$partial, z.ZodUnknown>>;
9
14
  concurrency: z.ZodOptional<z.ZodUnknown>;
10
15
  taskTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
11
16
  runTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
@@ -16,6 +21,7 @@ export declare const remoteConfigSchema: z.ZodObject<{
16
21
  include: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
22
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
23
  reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
24
+ roles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
19
25
  github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
20
26
  providers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
21
27
  rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -11,6 +11,7 @@ export const remoteConfigSchema = z
11
11
  .object({
12
12
  $schema: z.string().optional(),
13
13
  models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
14
+ effort: z.partialRecord(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
14
15
  concurrency: z.unknown().optional(),
15
16
  taskTimeoutMinutes: z.unknown().optional(),
16
17
  runTimeoutMinutes: z.unknown().optional(),
@@ -21,6 +22,7 @@ export const remoteConfigSchema = z
21
22
  include: z.array(z.string()).optional(),
22
23
  exclude: z.array(z.string()).optional(),
23
24
  reviewers: z.record(z.string(), z.unknown()).optional(),
25
+ roles: z.record(z.string(), z.unknown()).optional(),
24
26
  github: z.record(z.string(), z.unknown()).optional(),
25
27
  providers: z.record(z.string(), z.unknown()).optional(),
26
28
  rules: z.array(repoRuleSchema).max(500).optional(),
@@ -83,7 +85,7 @@ async function readLimited(response, limit, name) {
83
85
  export function mergeConfig(remote, local) {
84
86
  const { rules: _rules, ...settings } = remote;
85
87
  const merged = { ...settings, ...local };
86
- for (const key of ["models", "reviewers", "github", "providers"]) {
88
+ for (const key of ["models", "effort", "reviewers", "roles", "github", "providers"]) {
87
89
  const a = settings[key];
88
90
  const b = local[key];
89
91
  if (a !== undefined || b !== undefined)
@@ -31,7 +31,7 @@ export function renderSarif(report, version) {
31
31
  tool: {
32
32
  driver: {
33
33
  name: "ocra",
34
- informationUri: "https://ocra.majincheng.com",
34
+ informationUri: "https://ocracloud.com",
35
35
  version,
36
36
  semanticVersion: version,
37
37
  rules: categories.map((id) => ({
@@ -90,7 +90,7 @@ const GITLAB_API = "https://gitlab.com/api/v4";
90
90
  export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
91
91
  const token = env.GITLAB_TOKEN;
92
92
  if (!token) {
93
- throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN: a project access token with the api scope and the Developer role (CI_JOB_TOKEN cannot post comments)");
93
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN with the api scope and the Developer role: a project access token, or on GitLab.com Free a personal access token of a dedicated account (CI_JOB_TOKEN cannot post comments)");
94
94
  }
95
95
  const origin = await originUrl(root);
96
96
  const project = mr.project ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
package/dist/run.js CHANGED
@@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises";
2
2
  import { parseArgs } from "node:util";
3
3
  import { isOcraError } from "@open-cr-agent/core";
4
4
  import { errorMessage } from "@open-cr-agent/core/internal";
5
+ import { cloudCommand, defaultCloudDeps, LOGIN_USAGE } from "./cloud.js";
5
6
  import { memoryCommand } from "./memory.js";
6
7
  import { metricsCommand } from "./metrics.js";
7
8
  import { parseReviewArgs, REVIEW_USAGE, UsageError } from "./review/args.js";
@@ -15,6 +16,9 @@ Commands:
15
16
  review Review code changes (run "ocra review --help" for options)
16
17
  memory Remember findings the team accepts (run "ocra memory --help")
17
18
  metrics Counts over past reviews: runs, cost, findings, per reviewer (run "ocra metrics --help")
19
+ login Sign in to ocra Cloud (in development; run "ocra login --help")
20
+ logout Sign out of ocra Cloud
21
+ whoami Show the ocra Cloud account
18
22
 
19
23
  Options:
20
24
  -h, --help Show help
@@ -27,6 +31,7 @@ export function defaultDeps() {
27
31
  builtinPlugins: BUILTIN_PLUGINS,
28
32
  runtimes: BUILTIN_RUNTIMES,
29
33
  writeFile: (path, content) => writeFile(path, content, "utf8"),
34
+ cloud: defaultCloudDeps(process.env),
30
35
  now: Date.now,
31
36
  heartbeatMs: 30_000,
32
37
  onInterrupt(handler) {
@@ -61,6 +66,18 @@ export async function run(argv, out, err, deps = defaultDeps()) {
61
66
  return EXIT.error;
62
67
  }
63
68
  }
69
+ if (command === "login" || command === "logout" || command === "whoami") {
70
+ try {
71
+ return await cloudCommand(command, rest, out, err, defaultCloudDeps(deps.env));
72
+ }
73
+ catch (error) {
74
+ if (error instanceof UsageError)
75
+ err.write(`${error.message}\n\n${LOGIN_USAGE}`);
76
+ else
77
+ err.write(failure(error));
78
+ return EXIT.error;
79
+ }
80
+ }
64
81
  if (command !== undefined && !command.startsWith("-")) {
65
82
  err.write(`Unknown command: ${command}\n\n${USAGE}`);
66
83
  return EXIT.error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "ocra: open-source multi-agent code review for local changes and GitHub pull requests",
5
5
  "keywords": [
6
6
  "code-review",
@@ -10,7 +10,7 @@
10
10
  "github-action"
11
11
  ],
12
12
  "license": "Apache-2.0",
13
- "homepage": "https://ocra.majincheng.com",
13
+ "homepage": "https://ocracloud.com",
14
14
  "repository": {
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/jma49/Open-CR-Agent.git",
@@ -37,15 +37,15 @@
37
37
  "!dist/**/*.map"
38
38
  ],
39
39
  "dependencies": {
40
- "@open-cr-agent/core": "0.3.0",
41
- "@open-cr-agent/runtime-direct": "0.3.0",
42
- "@open-cr-agent/vcs-github": "0.3.0",
43
- "@open-cr-agent/vcs-gitlab": "0.3.0",
44
- "@open-cr-agent/vcs-local": "0.3.0",
40
+ "@open-cr-agent/core": "0.4.0",
41
+ "@open-cr-agent/runtime-direct": "0.4.0",
42
+ "@open-cr-agent/vcs-github": "0.4.0",
43
+ "@open-cr-agent/vcs-gitlab": "0.4.0",
44
+ "@open-cr-agent/vcs-local": "0.4.0",
45
45
  "zod": "^4.6.5"
46
46
  },
47
47
  "optionalDependencies": {
48
- "@open-cr-agent/runtime-opencode": "0.3.0"
48
+ "@open-cr-agent/runtime-opencode": "0.4.0"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"