mergestorm 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.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # mergestorm CLI
2
+
3
+ Review a local git diff with MergeStorm without opening a GitHub PR — for local loops and CI. Diffs are reviewed by the same engine as PR reviews and each review consumes one standard credit.
4
+
5
+ Requires **Node.js 22+**.
6
+
7
+ ## Install
8
+
9
+ One-liner (installs the global `mergestorm` binary, then sign in via your browser):
10
+
11
+ ```bash
12
+ curl -fsSL https://mergestorm.ai/install.sh | bash
13
+ mergestorm login
14
+ ```
15
+
16
+ Or install directly with npm:
17
+
18
+ ```bash
19
+ npm install -g mergestorm
20
+ mergestorm login
21
+ ```
22
+
23
+ ### From this monorepo (development)
24
+
25
+ ```bash
26
+ npm run build --workspace=mergestorm
27
+ node packages/mergestorm-cli/dist/cli.js <command>
28
+ # or skip the build during dev (runs src/cli.ts via tsx):
29
+ npm run dev --workspace=mergestorm -- <command>
30
+ ```
31
+
32
+ ## Commands
33
+
34
+ ```
35
+ mergestorm login Sign in via browser; stores an API key in ~/.mergestorm/config.json
36
+ mergestorm login --key Paste an existing API key instead (headless/CI)
37
+ mergestorm logout Remove the stored API key
38
+ mergestorm review [base] [head] Review git diff base...head (default: main...HEAD)
39
+ mergestorm status <job_id> Poll a review job and print its result
40
+ ```
41
+
42
+ ### `login`
43
+
44
+ Runs a browser device-authorization flow: prints a short code, opens `https://mergestorm.ai/cli/auth`, and — once you approve — mints and stores an API key in `~/.mergestorm/config.json` (mode `600`). No key copy/paste needed.
45
+
46
+ For headless or CI environments, use `mergestorm login --key` to paste an existing `msk_live_…` key (create one on the dashboard [Account page](https://mergestorm.ai/dashboard/account) → **API keys**).
47
+
48
+ ### `logout`
49
+
50
+ Removes `~/.mergestorm/config.json` so the stored key is no longer used.
51
+
52
+ ### `review [base] [head]`
53
+
54
+ Collects `git diff base...head` (default `main...HEAD`), attaches the changed file contents (up to 40 files, skipping files > 400 KB), auto-attaches context from `./.mergestorm/**/*.md|*.txt`, submits the job, and polls until it finishes (~3 min max). The thread slug is derived from the current branch (`local/<branch>`), so repeated runs on the same branch chain their findings.
55
+
56
+ ```bash
57
+ mergestorm review # main...HEAD
58
+ mergestorm review develop # develop...HEAD
59
+ mergestorm review main feat/x # main...feat/x
60
+ ```
61
+
62
+ Exit code is non-zero on quota limit (402), submission failure, a `failed`/`quota_exceeded` job, or a poll timeout.
63
+
64
+ ### `status <job_id>`
65
+
66
+ Fetches a job by id and prints the raw JSON (status, verdict, summary, findings).
67
+
68
+ ## Context pack (`.mergestorm/`)
69
+
70
+ Any `*.md` / `*.txt` files under `./.mergestorm/` (one level of nesting) are attached as context so the reviewer understands intent, conventions, or migration notes without you resending history.
71
+
72
+ ## Environment
73
+
74
+ | Variable | Purpose | Default |
75
+ |----------|---------|---------|
76
+ | `MERGESTORM_API_KEY` | API key (overrides the stored config) | — |
77
+ | `MERGESTORM_API_URL` | API base URL | `https://api.mergestorm.ai` |
78
+
79
+ ## See also
80
+
81
+ - [Review Jobs API reference](../../docs/review-jobs-api.md) — raw HTTP endpoints, webhook signing, thread chains, quota.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { mkdir, readFile, writeFile, rm, readdir, stat } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
5
+ import path from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { stdin as input, stdout as output } from "node:process";
8
+ const DEFAULT_API = "https://api.mergestorm.ai";
9
+ const CONTEXT_DIR = ".mergestorm";
10
+ function configPath() {
11
+ return path.join(homedir(), ".mergestorm", "config.json");
12
+ }
13
+ async function loadConfig() {
14
+ try {
15
+ const raw = await readFile(configPath(), "utf8");
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ async function saveConfig(cfg) {
23
+ const dir = path.dirname(configPath());
24
+ await mkdir(dir, { recursive: true });
25
+ await writeFile(configPath(), JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
26
+ }
27
+ function apiBase(cfg) {
28
+ return (process.env.MERGESTORM_API_URL?.trim() || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
29
+ }
30
+ async function apiFetch(cfg, route, init = {}) {
31
+ const key = process.env.MERGESTORM_API_KEY?.trim() || cfg.apiKey;
32
+ if (!key) {
33
+ throw new Error("No API key. Run `mergestorm login` or set MERGESTORM_API_KEY.");
34
+ }
35
+ const headers = {
36
+ Authorization: `Bearer ${key}`,
37
+ ...init.headers,
38
+ };
39
+ let body = init.body;
40
+ if (init.json !== undefined) {
41
+ headers["Content-Type"] = "application/json";
42
+ body = JSON.stringify(init.json);
43
+ }
44
+ const res = await fetch(`${apiBase(cfg)}${route}`, { ...init, headers, body });
45
+ const text = await res.text();
46
+ let parsed = text;
47
+ try {
48
+ parsed = JSON.parse(text);
49
+ }
50
+ catch {
51
+ // keep text
52
+ }
53
+ return { status: res.status, body: parsed };
54
+ }
55
+ function git(args, cwd = process.cwd()) {
56
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
57
+ if (result.status !== 0) {
58
+ throw new Error(result.stderr?.trim() || `git ${args.join(" ")} failed`);
59
+ }
60
+ return result.stdout;
61
+ }
62
+ async function collectContextFiles(root) {
63
+ const pack = path.join(root, CONTEXT_DIR);
64
+ let entries;
65
+ try {
66
+ entries = await readdir(pack, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ const files = [];
72
+ for (const entry of entries) {
73
+ if (entry.isFile() && (entry.name.toLowerCase().endsWith(".md") || entry.name.toLowerCase().endsWith(".txt"))) {
74
+ const content = await readFile(path.join(pack, entry.name), "utf8");
75
+ files.push({ path: entry.name, content });
76
+ }
77
+ else if (entry.isDirectory() && !entry.name.startsWith(".")) {
78
+ const nested = await readdir(path.join(pack, entry.name), { withFileTypes: true }).catch(() => []);
79
+ for (const child of nested) {
80
+ if (child.isFile() && (child.name.toLowerCase().endsWith(".md") || child.name.toLowerCase().endsWith(".txt"))) {
81
+ const rel = `${entry.name}/${child.name}`;
82
+ const content = await readFile(path.join(pack, entry.name, child.name), "utf8");
83
+ files.push({ path: rel, content });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return files;
89
+ }
90
+ async function collectChangedFiles(base, head) {
91
+ const names = git(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`])
92
+ .split("\n")
93
+ .map((s) => s.trim())
94
+ .filter(Boolean);
95
+ const files = [];
96
+ for (const name of names.slice(0, 40)) {
97
+ try {
98
+ const st = await stat(path.join(process.cwd(), name));
99
+ if (!st.isFile() || st.size > 400_000)
100
+ continue;
101
+ const content = await readFile(path.join(process.cwd(), name), "utf8");
102
+ files.push({ path: name, content });
103
+ }
104
+ catch {
105
+ // deleted or unreadable
106
+ }
107
+ }
108
+ return files;
109
+ }
110
+ function printBanner() {
111
+ console.log(`
112
+ ┌─────────────────────────────┐
113
+ │ MergeStorm CLI │
114
+ │ review local diffs fast │
115
+ └─────────────────────────────┘
116
+ `);
117
+ }
118
+ function usage() {
119
+ printBanner();
120
+ console.log(`Usage:
121
+ mergestorm login Sign in via browser and store an API key
122
+ mergestorm login --key Paste an existing API key instead (headless/CI)
123
+ mergestorm logout Remove the stored API key
124
+ mergestorm review [base] [head] Review git diff base...head (default: main...HEAD)
125
+ mergestorm status <job_id> Poll a review job
126
+
127
+ Env:
128
+ MERGESTORM_API_KEY API key (overrides config)
129
+ MERGESTORM_API_URL API base (default ${DEFAULT_API})
130
+ `);
131
+ }
132
+ /** Best-effort open a URL in the user's browser; silent if no opener exists. */
133
+ function openBrowser(url) {
134
+ const os = platform();
135
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "cmd" : "xdg-open";
136
+ const args = os === "win32" ? ["/c", "start", "", url] : [url];
137
+ try {
138
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
139
+ child.on("error", () => { });
140
+ child.unref();
141
+ }
142
+ catch {
143
+ // no-op — the URL is printed for manual open
144
+ }
145
+ }
146
+ async function devicePost(base, route, json) {
147
+ const res = await fetch(`${base}${route}`, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify(json),
151
+ });
152
+ const text = await res.text();
153
+ let parsed = text;
154
+ try {
155
+ parsed = JSON.parse(text);
156
+ }
157
+ catch {
158
+ // keep text
159
+ }
160
+ return { status: res.status, body: parsed };
161
+ }
162
+ async function cmdLoginWithKey() {
163
+ const rl = createInterface({ input, output });
164
+ try {
165
+ const key = (await rl.question("Paste your MergeStorm API key (msk_live_...): ")).trim();
166
+ if (!key.startsWith("msk_live_")) {
167
+ console.error("Key should start with msk_live_");
168
+ process.exitCode = 1;
169
+ return;
170
+ }
171
+ const cfg = await loadConfig();
172
+ cfg.apiKey = key;
173
+ await saveConfig(cfg);
174
+ console.log(`Saved to ${configPath()}`);
175
+ }
176
+ finally {
177
+ rl.close();
178
+ }
179
+ }
180
+ async function cmdLogin(args) {
181
+ printBanner();
182
+ if (args.includes("--key")) {
183
+ await cmdLoginWithKey();
184
+ return;
185
+ }
186
+ const cfg = await loadConfig();
187
+ const base = apiBase(cfg);
188
+ let start;
189
+ try {
190
+ start = await devicePost(base, "/api/v1/cli/device", {});
191
+ }
192
+ catch {
193
+ console.error("Could not start device login. The API may be unreachable or misconfigured.\n" +
194
+ "Run `mergestorm login --key` to paste an existing API key instead.");
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+ if (start.status !== 200 || !start.body?.device_code) {
199
+ console.error(`Could not start device login (HTTP ${start.status}). The API may be unreachable or misconfigured.\n` +
200
+ `Run \`mergestorm login --key\` to paste an existing API key instead.`);
201
+ process.exitCode = 1;
202
+ return;
203
+ }
204
+ const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in, } = start.body;
205
+ const openUrl = verification_uri_complete || verification_uri;
206
+ console.log("To sign in, open this URL and confirm the code:\n");
207
+ console.log(` ${openUrl}`);
208
+ console.log(`\n Code: ${user_code}\n`);
209
+ openBrowser(openUrl);
210
+ console.log("Waiting for approval…");
211
+ const pollMs = Math.max((interval || 5), 2) * 1000;
212
+ const deadline = Date.now() + (expires_in || 600) * 1000;
213
+ while (Date.now() < deadline) {
214
+ await new Promise((r) => setTimeout(r, pollMs));
215
+ let poll;
216
+ try {
217
+ poll = await devicePost(base, "/api/v1/cli/device/token", { device_code });
218
+ }
219
+ catch {
220
+ console.error("\nLogin failed: network error. Check your connection and run `mergestorm login` again.");
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ if (poll.status === 200 && poll.body?.api_key) {
225
+ const next = await loadConfig();
226
+ next.apiKey = poll.body.api_key;
227
+ await saveConfig(next);
228
+ console.log(`\nLogged in. Key saved to ${configPath()}`);
229
+ return;
230
+ }
231
+ const err = poll.body?.error;
232
+ if (err === "authorization_pending") {
233
+ process.stdout.write(".");
234
+ continue;
235
+ }
236
+ if (err === "key_limit_reached") {
237
+ console.error("\nYou have reached the max active API keys. Revoke one at https://mergestorm.ai/dashboard/account and try again.");
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ if (err === "expired_token") {
242
+ console.error("\nLogin code expired. Run `mergestorm login` again.");
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ if (err === "already_redeemed") {
247
+ console.error("\nThis code was already used. Run `mergestorm login` again.");
248
+ process.exitCode = 1;
249
+ return;
250
+ }
251
+ console.error(`\nLogin failed: ${typeof err === "string" ? err : JSON.stringify(poll.body)}`);
252
+ process.exitCode = 1;
253
+ return;
254
+ }
255
+ console.error("\nLogin timed out. Run `mergestorm login` again.");
256
+ process.exitCode = 1;
257
+ }
258
+ async function cmdLogout() {
259
+ try {
260
+ await rm(configPath(), { force: true });
261
+ console.log("Logged out. Removed stored API key.");
262
+ }
263
+ catch (err) {
264
+ console.error("Could not remove config:", err instanceof Error ? err.message : err);
265
+ process.exitCode = 1;
266
+ }
267
+ }
268
+ async function cmdReview(args) {
269
+ const base = args[0] || "main";
270
+ const head = args[1] || "HEAD";
271
+ const cfg = await loadConfig();
272
+ console.log(`Collecting diff ${base}...${head} …`);
273
+ const diff = git(["diff", `${base}...${head}`]);
274
+ if (!diff.trim()) {
275
+ console.log("No changes to review.");
276
+ return;
277
+ }
278
+ const files = await collectChangedFiles(base, head);
279
+ const context_files = await collectContextFiles(process.cwd());
280
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim() || "local";
281
+ const thread = `local/${branch}`.replace(/[^a-zA-Z0-9._/-]/g, "-").slice(0, 120);
282
+ console.log(`Submitting review (${Buffer.byteLength(diff, "utf8")} byte diff, ${files.length} file(s), ${context_files.length} context file(s))…`);
283
+ const { status, body } = await apiFetch(cfg, "/api/v1/reviews", {
284
+ method: "POST",
285
+ json: {
286
+ thread,
287
+ base_label: base,
288
+ head_label: head,
289
+ diff,
290
+ files,
291
+ context_files,
292
+ },
293
+ });
294
+ if (status === 402) {
295
+ console.error("Monthly review limit reached. Upgrade at https://mergestorm.ai/billing");
296
+ process.exitCode = 1;
297
+ return;
298
+ }
299
+ if (status !== 202 && status !== 200) {
300
+ console.error("Failed:", JSON.stringify(body, null, 2));
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+ const job = body;
305
+ console.log(`Job ${job.job_id} (${job.status}). Waiting…`);
306
+ for (let i = 0; i < 90; i += 1) {
307
+ await new Promise((r) => setTimeout(r, 2000));
308
+ const poll = await apiFetch(cfg, `/api/v1/reviews/${job.job_id}`);
309
+ if (poll.status !== 200) {
310
+ console.error("\nPoll failed:", JSON.stringify(poll.body, null, 2));
311
+ process.exitCode = 1;
312
+ return;
313
+ }
314
+ const row = poll.body;
315
+ if (row.status === "queued" || row.status === "in_progress") {
316
+ process.stdout.write(".");
317
+ continue;
318
+ }
319
+ console.log("");
320
+ if (row.status === "failed" || row.status === "quota_exceeded") {
321
+ console.error(`Review ${row.status}: ${row.error ?? ""}`);
322
+ process.exitCode = 1;
323
+ return;
324
+ }
325
+ console.log(`\nVerdict: ${row.verdict ?? "comment"}`);
326
+ console.log(row.summary ?? "");
327
+ const inline = row.findings?.inline ?? [];
328
+ const off = row.findings?.off_diff ?? row.findings?.offDiff ?? [];
329
+ for (const c of inline) {
330
+ console.log(`\n[${c.severity}] ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
331
+ console.log(c.body);
332
+ }
333
+ for (const c of off) {
334
+ console.log(`\n[${c.severity}] off-diff ${c.path ?? ""}`);
335
+ console.log(c.body);
336
+ }
337
+ if (inline.length === 0 && off.length === 0) {
338
+ console.log("\nNo findings.");
339
+ }
340
+ return;
341
+ }
342
+ console.error("\nTimed out waiting for review. Check: mergestorm status " + job.job_id);
343
+ process.exitCode = 1;
344
+ }
345
+ async function cmdStatus(jobId) {
346
+ const cfg = await loadConfig();
347
+ const { status, body } = await apiFetch(cfg, `/api/v1/reviews/${jobId}`);
348
+ if (status !== 200) {
349
+ console.error(JSON.stringify(body, null, 2));
350
+ process.exitCode = 1;
351
+ return;
352
+ }
353
+ console.log(JSON.stringify(body, null, 2));
354
+ }
355
+ async function main() {
356
+ const [, , cmd, ...args] = process.argv;
357
+ if (!cmd || cmd === "-h" || cmd === "--help") {
358
+ usage();
359
+ return;
360
+ }
361
+ if (cmd === "login")
362
+ return cmdLogin(args);
363
+ if (cmd === "logout")
364
+ return cmdLogout();
365
+ if (cmd === "review")
366
+ return cmdReview(args);
367
+ if (cmd === "status") {
368
+ if (!args[0]) {
369
+ console.error("usage: mergestorm status <job_id>");
370
+ process.exitCode = 1;
371
+ return;
372
+ }
373
+ return cmdStatus(args[0]);
374
+ }
375
+ usage();
376
+ process.exitCode = 1;
377
+ }
378
+ main().catch((err) => {
379
+ console.error(err instanceof Error ? err.message : err);
380
+ process.exitCode = 1;
381
+ });
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "mergestorm",
3
+ "version": "0.1.0",
4
+ "description": "MergeStorm CLI — review local diffs via the Review Jobs API",
5
+ "type": "module",
6
+ "bin": {
7
+ "mergestorm": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "dev": "tsx src/cli.ts",
15
+ "start": "node dist/cli.js",
16
+ "typecheck": "tsc -p tsconfig.json --noEmit"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.0.0"
20
+ },
21
+ "dependencies": {},
22
+ "devDependencies": {
23
+ "@types/node": "^22.10.2",
24
+ "tsx": "^4.19.2",
25
+ "typescript": "^5.7.2"
26
+ }
27
+ }