@expo/code-review-cli 0.5.0 → 0.5.1

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
@@ -26,7 +26,12 @@ flowchart TD
26
26
  ## Usage
27
27
 
28
28
  Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
29
- binary once installed).
29
+ binary once installed). On a repo that already has `.expo-code-review/` set up,
30
+ getting model credentials for local runs is one command:
31
+
32
+ ```bash
33
+ npx @expo/code-review-cli setup-auth
34
+ ```
30
35
 
31
36
  Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
32
37
  Everything else the reviewer needs (including the `opencode` runtime) ships with the
@@ -34,34 +39,24 @@ package.
34
39
 
35
40
  ### First-time setup
36
41
 
37
- Scaffold, add credentials, verify.
38
-
39
42
  ```bash
40
- # Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
43
+ # 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
41
44
  npx @expo/code-review-cli init
42
- ```
43
-
44
- Then give it model credentials. **Default: an OpenAI API key** — the scaffolded
45
- config reviews with GPT via `auth.mode "api-key"`.
46
-
47
- Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
48
-
49
- - Put it in a **dedicated project** (not "Default project") so you can set a
50
- monthly budget + alert on it and see the reviewer's spend in isolation.
51
- - Make it a **Restricted** key with exactly two permissions, both under *Model
52
- capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
53
- (/v1/chat/completions) → Request**. Everything else — including *List models* —
54
- stays **None** (the reviewer resolves model ids from its own catalog and only
55
- ever makes inference requests).
56
-
57
- ```bash
58
- export OPENAI_API_KEY=sk-proj-...
59
- # Check env, config, and credentials
45
+ # 2. Get model credentials — guided; prints the export lines for your shell config
46
+ npx @expo/code-review-cli setup-auth
47
+ # 3. Verify env, config, and credentials
60
48
  npx @expo/code-review-cli doctor
61
49
  ```
62
50
 
63
- In CI, store the same key as the `OPENAI_API_KEY` repo secret (the scaffolded
64
- workflow forwards it).
51
+ `setup-auth` reads the repo's config and walks through each credential it needs:
52
+ an OpenAI **API key** (the scaffolded default — it prints where to create the key
53
+ and the exact restricted permissions to grant), and/or a **ChatGPT/Codex
54
+ subscription** sign-in (it runs OpenCode's browser login and extracts the token
55
+ for you). `doctor` offers to run it whenever a credential is missing.
56
+
57
+ In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
58
+ `CODEX_OAUTH_REFRESH_TOKEN` for the mixed setup) — the scaffolded workflow
59
+ forwards them.
65
60
 
66
61
  **Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
67
62
  production setup pairs the subscription (runs the default models at no marginal
@@ -115,6 +110,7 @@ is a ready example to adapt.
115
110
  | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
116
111
  | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
117
112
  | `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
113
+ | `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT sign-in and/or API keys), printing the `export` lines for your shell config. |
118
114
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
119
115
  | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
120
116
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
@@ -508,9 +504,11 @@ set in `config.auth` (credentials come from OpenCode):
508
504
  access tokens are short-lived, so the refresh token is the durable secret and
509
505
  OpenCode mints access tokens on demand. Refresh-token reuse across runs is
510
506
  verified, so a static CI secret works.
511
- - **The API key needs the same two permissions** as the default setup above
512
- (Responses + Chat completions → Request; all else None), in a budget-capped
513
- project.
507
+ - **The API key needs exactly two permissions** a *Restricted* key with
508
+ *Model capabilities*: **Responses → Request** and **Chat completions
509
+ Request**; everything else (including *List models*) stays None. Create it
510
+ in a dedicated, budget-capped project. (`ecr setup-auth` prints these
511
+ instructions too.)
514
512
  - **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
515
513
  comma-separated set of both env names
516
514
  (`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
package/build/cli.js CHANGED
@@ -4,6 +4,7 @@ import { dismissCommand } from "./commands/dismiss.js";
4
4
  import { doctorCommand } from "./commands/doctor.js";
5
5
  import { initCommand } from "./commands/init.js";
6
6
  import { reviewCommand } from "./commands/review.js";
7
+ import { setupAuthCommand } from "./commands/setup-auth.js";
7
8
  import { verifyConfigCommand } from "./commands/verify-config.js";
8
9
  const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
9
10
 
@@ -13,6 +14,7 @@ Usage:
13
14
  ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
14
15
  ecr undismiss --pr <n> <id...> Restore a dismissed finding.
15
16
  ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
17
+ ecr setup-auth [--yes] Walk through getting model credentials for local runs.
16
18
  ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
17
19
  ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
18
20
 
@@ -47,6 +49,9 @@ async function main() {
47
49
  case "init":
48
50
  await initCommand(rest);
49
51
  break;
52
+ case "setup-auth":
53
+ await setupAuthCommand(rest);
54
+ break;
50
55
  case "doctor":
51
56
  await doctorCommand(rest);
52
57
  break;
@@ -1,5 +1,7 @@
1
1
  import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
2
2
  import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
3
+ import readline from "node:readline/promises";
4
+ import { setupAuthCommand } from "./setup-auth.js";
3
5
  import { checkProviderAuth } from "../core/auth.js";
4
6
  import { opencodeBinSource } from "../core/opencode.js";
5
7
  import { git, onPath, repoRoot, run } from "../core/exec.js";
@@ -126,6 +128,29 @@ export async function doctorCommand(argv = []) {
126
128
  if (readiness.warning) {
127
129
  warn(`auth: ${readiness.warning}`);
128
130
  }
131
+ // A missing credential has a guided fix — offer it right here when someone is
132
+ // at the terminal, rather than making them find the command in the README.
133
+ if (!readiness.ok) {
134
+ if (process.stdin.isTTY && process.stdout.isTTY) {
135
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
136
+ let runIt = false;
137
+ try {
138
+ const answer = (await rl.question(" Run `ecr setup-auth` to fix this now? [Y/n] "))
139
+ .trim()
140
+ .toLowerCase();
141
+ runIt = answer === "" || answer === "y" || answer === "yes";
142
+ }
143
+ finally {
144
+ rl.close();
145
+ }
146
+ if (runIt) {
147
+ await setupAuthCommand([]);
148
+ }
149
+ }
150
+ else {
151
+ info("run `ecr setup-auth` for a guided credential setup");
152
+ }
153
+ }
129
154
  }
130
155
  catch (error) {
131
156
  line(false, `config invalid: ${errorMessage(error)}`);
@@ -0,0 +1,200 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import readline from "node:readline/promises";
6
+ import { hasConfig, loadReviewConfig } from "../config/load.js";
7
+ import { opencodeBinSource } from "../core/opencode.js";
8
+ import { errorMessage } from "../core/util.js";
9
+ const USAGE = `ecr setup-auth — set up model credentials for local runs
10
+
11
+ Reads this repo's .expo-code-review/config.jsonc auth entries and walks through
12
+ getting each credential:
13
+ • a ChatGPT/Codex subscription (oauth/openai): runs the bundled
14
+ \`opencode auth login\` (interactive; opens your browser), then prints the
15
+ \`export <tokenEnv>=…\` line to add to your shell config. An existing
16
+ OpenCode ChatGPT sign-in is reused instead of re-authenticating.
17
+ • an API key (api-key entries): prints where to create the key, the exact
18
+ permissions it needs, and the export line to fill in.
19
+
20
+ Without a repo config, it offers the recommended ChatGPT/Codex subscription flow
21
+ with the default env name.
22
+
23
+ Options:
24
+ --yes Skip confirmation prompts (still interactive during the login itself).
25
+ `;
26
+ export function planFromAuth(auth) {
27
+ const plan = { manualKeys: [], unsupported: [] };
28
+ for (const entry of auth) {
29
+ if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
30
+ plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
31
+ }
32
+ else if (entry.mode === "api-key" && entry.tokenEnv) {
33
+ plan.manualKeys.push({
34
+ provider: entry.provider,
35
+ tokenEnv: entry.tokenEnv,
36
+ upstream: entry.upstream,
37
+ });
38
+ }
39
+ else if (entry.mode === "oauth") {
40
+ plan.unsupported.push(entry);
41
+ }
42
+ // api-key without tokenEnv relies on OpenCode's own login — nothing to set up.
43
+ }
44
+ return plan;
45
+ }
46
+ /** Where OpenCode's own (non-isolated) auth.json lives. */
47
+ export function opencodeAuthJsonPath(env = process.env) {
48
+ const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
49
+ return path.join(dataHome, "opencode", "auth.json");
50
+ }
51
+ /** The stored ChatGPT sign-in's refresh token, if OpenCode has one. */
52
+ async function readStoredRefreshToken() {
53
+ try {
54
+ const raw = await readFile(opencodeAuthJsonPath(), "utf8");
55
+ const parsed = JSON.parse(raw);
56
+ const openai = parsed.openai;
57
+ return openai?.type === "oauth" && openai.refresh ? openai.refresh : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ async function confirm(question, skip) {
64
+ if (skip) {
65
+ return true;
66
+ }
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
68
+ try {
69
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
70
+ return answer === "" || answer === "y" || answer === "yes";
71
+ }
72
+ finally {
73
+ rl.close();
74
+ }
75
+ }
76
+ /** The line to paste into a shell config. Single-quoted: tokens never contain '. */
77
+ export function exportLine(tokenEnv, value) {
78
+ return `export ${tokenEnv}='${value}'`;
79
+ }
80
+ export async function setupAuthCommand(argv = []) {
81
+ if (argv.includes("-h") || argv.includes("--help")) {
82
+ process.stdout.write(USAGE);
83
+ return;
84
+ }
85
+ const yes = argv.includes("--yes");
86
+ const out = (line = "") => process.stdout.write(`${line}\n`);
87
+ const err = (line = "") => process.stderr.write(`${line}\n`);
88
+ try {
89
+ // Plan from the repo config when there is one; otherwise offer the
90
+ // recommended subscription flow with the default env name.
91
+ let plan;
92
+ if (hasConfig(process.cwd())) {
93
+ const config = await loadReviewConfig(process.cwd());
94
+ plan = planFromAuth(config.auth);
95
+ }
96
+ else {
97
+ err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
98
+ plan = planFromAuth([
99
+ { provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
100
+ ]);
101
+ }
102
+ if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
103
+ out("This repo's auth config needs no local credential setup (OpenCode's own login covers it).");
104
+ return;
105
+ }
106
+ const exports = [];
107
+ if (plan.chatgptLogin) {
108
+ const { tokenEnv } = plan.chatgptLogin;
109
+ if (process.env[tokenEnv]) {
110
+ err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
111
+ }
112
+ else {
113
+ let refresh = await readStoredRefreshToken();
114
+ if (refresh) {
115
+ err("Found an existing ChatGPT sign-in in OpenCode.");
116
+ if (!(await confirm(`Reuse it for ${tokenEnv}?`, yes))) {
117
+ refresh = null;
118
+ }
119
+ }
120
+ if (!refresh) {
121
+ err("This will run the bundled `opencode auth login` (interactive).");
122
+ err("When it prompts:");
123
+ err(" 1. select the provider: OpenAI");
124
+ err(" 2. select the method: Sign in with ChatGPT (Codex subscription)");
125
+ err(" 3. your browser opens — sign in and authorize.");
126
+ if (!(await confirm("Run it now?", yes))) {
127
+ err("Skipped the ChatGPT sign-in.");
128
+ }
129
+ else {
130
+ const binDir = opencodeBinSource().dir;
131
+ const opencode = binDir ? path.join(binDir, "opencode") : "opencode";
132
+ const result = spawnSync(opencode, ["auth", "login"], { stdio: "inherit" });
133
+ if (result.status !== 0) {
134
+ throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
135
+ }
136
+ refresh = await readStoredRefreshToken();
137
+ if (!refresh) {
138
+ throw new Error("The login finished but no ChatGPT sign-in was stored — did you select " +
139
+ 'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.');
140
+ }
141
+ }
142
+ }
143
+ if (refresh) {
144
+ // The REFRESH token is the durable secret: access tokens are short-lived,
145
+ // and OpenCode mints them from this on demand.
146
+ exports.push(exportLine(tokenEnv, refresh));
147
+ }
148
+ }
149
+ }
150
+ for (const key of plan.manualKeys) {
151
+ if (process.env[key.tokenEnv]) {
152
+ err(`✓ ${key.tokenEnv} is already set in this shell — skipping.`);
153
+ continue;
154
+ }
155
+ const upstream = key.upstream ?? key.provider;
156
+ err("");
157
+ err(`${key.tokenEnv} (${key.provider}) is an API key — create it by hand:`);
158
+ if (upstream === "openai") {
159
+ err(" https://platform.openai.com/api-keys — in a dedicated project (set a");
160
+ err(" monthly budget), as a RESTRICTED key with exactly two permissions, both");
161
+ err(" under Model capabilities: Responses → Request, Chat completions → Request.");
162
+ err(" Everything else (including List models) stays None.");
163
+ }
164
+ else if (upstream === "anthropic") {
165
+ err(" https://console.anthropic.com/settings/keys — a workspace-scoped key");
166
+ err(" with a spend limit is all the reviewer needs.");
167
+ }
168
+ else {
169
+ err(` mint a key for the "${upstream}" provider.`);
170
+ }
171
+ exports.push(exportLine(key.tokenEnv, "<paste the key here>"));
172
+ }
173
+ for (const entry of plan.unsupported) {
174
+ err("");
175
+ err(`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
176
+ (entry.provider === "anthropic"
177
+ ? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
178
+ : `. Set ${entry.tokenEnv ?? "its token env"} manually.`));
179
+ }
180
+ if (exports.length > 0) {
181
+ const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
182
+ err("");
183
+ err(`Add ${exports.length === 1 ? "this line" : "these lines"} to ${rc}:`);
184
+ out("");
185
+ for (const line of exports) {
186
+ out(` ${line}`);
187
+ }
188
+ out("");
189
+ err(`Then restart your shell (or \`source ${rc}\`) and run \`ecr doctor\` to verify.`);
190
+ }
191
+ else {
192
+ err("");
193
+ err("Nothing to add — run `ecr doctor` to verify your setup.");
194
+ }
195
+ }
196
+ catch (error) {
197
+ err(`setup-auth failed: ${errorMessage(error)}`);
198
+ process.exitCode = 1;
199
+ }
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,7 +10,8 @@
10
10
  "type": "module",
11
11
  "bin": {
12
12
  "ecr": "build/cli.js",
13
- "expo-code-review": "build/cli.js"
13
+ "expo-code-review": "build/cli.js",
14
+ "code-review-cli": "build/cli.js"
14
15
  },
15
16
  "files": [
16
17
  "build",