@mergelight/cli 0.0.0-stage → 0.1.14

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,157 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync } from "node:fs";
3
+ import { agentEnv } from "./providers/codex.js";
4
+ /**
5
+ * Connects a Claude plan from a pull request, with no terminal.
6
+ *
7
+ * The unmodified `claude setup-token` runs in the customer's own GitHub Actions run. It makes the link, the
8
+ * person confirms on Claude's own page, and Claude shows them a code. They paste it on the setup page, which
9
+ * leaves it in their repository for this run to read. The code is useless without a secret that stays in this
10
+ * run, and the token it buys is saved as the repository's secret. None of it passes through Mergelight.
11
+ */
12
+ const WAIT_MS = 16 * 60_000;
13
+ const POLL_MS = 5_000;
14
+ const LINK_WAIT_MS = 45_000;
15
+ const TOKEN_WAIT_MS = 60_000;
16
+ function plain(output) {
17
+ return output
18
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
19
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
20
+ .replace(/\x1b[=>()][0-9A-B]?/g, "");
21
+ }
22
+ /** The sign-in link, once all of it has arrived. Only Claude's own site counts. */
23
+ export function parseClaudeLink(output) {
24
+ const found = plain(output).match(/https:\/\/claude\.(?:com|ai)\/\S+(?=\s)/)?.[0];
25
+ if (!found)
26
+ return null;
27
+ try {
28
+ const url = new URL(found);
29
+ return url.pathname.includes("oauth/authorize") && url.searchParams.has("code_challenge") ? found : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function parseClaudeToken(output) {
36
+ return plain(output).match(/\bsk-ant-oat[\w-]{20,}(?=\s)/)?.[0] ?? null;
37
+ }
38
+ /**
39
+ * The code, if this is one. `claude setup-token` only takes `code#state`, and its state is 43 characters.
40
+ */
41
+ export function readPastedCode(body) {
42
+ const text = body.trim();
43
+ return /^[\w.~-]{10,500}#[\w.~-]{20,500}$/.test(text) ? text : null;
44
+ }
45
+ /**
46
+ * Starts the sign-in. It needs a terminal, so `script` gives it one, wide enough that the link and the token
47
+ * stay on one line. Nothing it prints is logged, because the token is in there. Null if no link appears.
48
+ */
49
+ export function startClaudeSignIn(spawnImpl = spawn, wait = { link: LINK_WAIT_MS, token: TOKEN_WAIT_MS }) {
50
+ return new Promise((resolve) => {
51
+ let output = "";
52
+ let settled = false;
53
+ let onOutput = () => { };
54
+ let child;
55
+ try {
56
+ child = spawnImpl("script", ["-qfc", "stty cols 3000 rows 50; exec claude setup-token", "/dev/null"], { stdio: ["pipe", "pipe", "ignore"], env: { ...agentEnv(), TERM: "xterm-256color", BROWSER: "true" } });
57
+ }
58
+ catch {
59
+ resolve(null);
60
+ return;
61
+ }
62
+ const stop = () => {
63
+ try {
64
+ child.kill("SIGKILL");
65
+ }
66
+ catch {
67
+ // Already gone.
68
+ }
69
+ };
70
+ const timer = setTimeout(() => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ stop();
75
+ resolve(null);
76
+ }, wait.link);
77
+ child.on("error", () => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ resolve(null);
83
+ });
84
+ child.stdin.on?.("error", () => { });
85
+ const finish = (code) => new Promise((done) => {
86
+ output = "";
87
+ const giveUp = setTimeout(() => done(null), wait.token);
88
+ onOutput = () => {
89
+ const token = parseClaudeToken(output);
90
+ if (!token)
91
+ return;
92
+ clearTimeout(giveUp);
93
+ done(token);
94
+ };
95
+ // Pasted first and confirmed a moment later, as a person would.
96
+ child.stdin.write(code);
97
+ setTimeout(() => child.stdin.write("\r"), 400);
98
+ }).finally(stop);
99
+ child.stdout.setEncoding("utf8");
100
+ child.stdout.on("data", (text) => {
101
+ output += text;
102
+ onOutput();
103
+ if (settled)
104
+ return;
105
+ const url = parseClaudeLink(output);
106
+ if (!url)
107
+ return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ resolve({ url, finish, stop });
111
+ });
112
+ });
113
+ }
114
+ /**
115
+ * Where the setup page leaves the code: a Git reference outside branches and tags, so it shows nowhere on the
116
+ * pull request and notifies nobody. Only someone with write access can make one. The name comes from this
117
+ * sign-in's own state, so a run only ever finds the code meant for it. Null if the link has no usable state.
118
+ */
119
+ export function claudeCodeRef(claudeUrl) {
120
+ try {
121
+ const state = new URL(claudeUrl).searchParams.get("state") ?? "";
122
+ return /^[\w-]{20,200}$/.test(state) ? `mergelight/claude-code/s${state}` : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /** What's waiting at that reference, if the setup page has been there yet. */
129
+ export async function readCodeRef(get, ref) {
130
+ const refs = await get(`git/matching-refs/${ref}`);
131
+ const found = refs.find((r) => r.ref === `refs/${ref}` && r.object.type === "blob");
132
+ if (!found)
133
+ return null;
134
+ const blob = await get(`git/blobs/${found.object.sha}`);
135
+ return Buffer.from(blob.content, "base64").toString("utf8");
136
+ }
137
+ /** Waits for the code. A failed read just waits for the next one, and anything that isn't a whole code doesn't count. */
138
+ export async function waitForPastedCode(deps, timeoutMs = WAIT_MS, pollMs = POLL_MS) {
139
+ const now = deps.now ?? Date.now;
140
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
141
+ const end = now() + timeoutMs;
142
+ while (now() < end && !deps.stopped?.()) {
143
+ const code = readPastedCode((await deps.read().catch(() => null)) ?? "");
144
+ if (code)
145
+ return code;
146
+ await sleep(pollMs);
147
+ }
148
+ return null;
149
+ }
150
+ /** Hands the token to the rest of the job: hidden in its logs, and available to the steps that save and use it. */
151
+ export function shareClaudeToken(token, env = process.env, print = console.log) {
152
+ if (!/^sk-ant-oat[\w-]+$/.test(token))
153
+ throw new Error("That doesn't look like a Claude token.");
154
+ print(`::add-mask::${token}`);
155
+ if (env.GITHUB_ENV)
156
+ appendFileSync(env.GITHUB_ENV, `MERGELIGHT_CLAUDE_TOKEN=${token}\nMERGELIGHT_PROVIDER=claude\nMERGELIGHT_CONNECT=0\n`);
157
+ }
@@ -0,0 +1,319 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { FAILED_MARKER, PENDING_MARKER, pendingSummaryBody, SUMMARY_MARKER } from "./publish/markdown.js";
5
+ import { CHECK_FAILED_TITLE, readPrReviewState, writeCheck } from "./publish/publish.js";
6
+ import { stateBlock } from "./publish/state.js";
7
+ import { claudeCodeRef, readCodeRef, shareClaudeToken, startClaudeSignIn, waitForPastedCode } from "./connect-claude.js";
8
+ /**
9
+ * Connects a repository to ChatGPT from its own pull request, with no terminal.
10
+ *
11
+ * Codex's device login runs inside the customer's own GitHub Actions run. The link and code go in
12
+ * a comment on their private repository, and the login stays in the runner until the Action saves
13
+ * it as their secret. None of it passes through Mergelight.
14
+ */
15
+ /** Codex says the code lasts 15 minutes. */
16
+ export const LOGIN_TIMEOUT_MS = 16 * 60_000;
17
+ export const INSTALL_URL = "https://github.com/apps/mergelight-ai/installations/new";
18
+ export const CHATGPT_SECURITY_URL = "https://chatgpt.com/settings/security";
19
+ /**
20
+ * Reads the link and code from what `codex login --device-auth` prints. Only OpenAI's own link counts.
21
+ * Output arrives in pieces, so each one only counts once something follows it: a code cut off mid-way
22
+ * would be posted once and could never be corrected.
23
+ */
24
+ export function parseDeviceLogin(output) {
25
+ const clean = output.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
26
+ const url = clean.match(/https:\/\/auth\.openai\.com\/\S+(?=\s)/)?.[0];
27
+ const code = clean.match(/\b[A-Z0-9]{4}-[A-Z0-9]{4,}(?=\s)/)?.[0];
28
+ return url && code ? { url, code } : null;
29
+ }
30
+ /** Part of the email of the account in a Codex login, so people can see whose it is. Null if it can't be read. */
31
+ export function connectedAccount(authJson) {
32
+ try {
33
+ const { tokens } = JSON.parse(authJson);
34
+ const { email } = JSON.parse(Buffer.from(tokens.id_token.split(".")[1], "base64url").toString("utf8"));
35
+ if (typeof email !== "string")
36
+ return null;
37
+ const [name, domain] = email.split("@");
38
+ return name && domain ? `${name.slice(0, 2)}***@${domain}` : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /** Anyone who can read the repository could have entered the code, so say whose plan it is now, where it stays visible. */
45
+ export function connectedBody(account, secretName) {
46
+ const who = account ? `the ChatGPT account \`${account}\`` : "the ChatGPT account that entered the code";
47
+ return `🔌 Reviews on this repository now use ${who}. If that isn't yours, remove the \`${secretName}\` secret and push a commit to connect again.`;
48
+ }
49
+ /** The setup page reads the link and code from this hidden line, so a change to how the comment looks can't break it. */
50
+ export function connectData(login, claudeUrl) {
51
+ return `<!-- mergelight:connect:v1 ${JSON.stringify({ url: login?.url ?? "", code: login?.code ?? "", claudeUrl: claudeUrl ?? null }).replace(/--/g, "\\u002d\\u002d")} -->`;
52
+ }
53
+ const SETUP_URL = "https://mergelight.dev/setup/";
54
+ /** Opens the setup page on the step this pull request is waiting for. */
55
+ export function claudeSetupLink(repo, number) {
56
+ return repo ? `${SETUP_URL}?repo=${repo}&pr=${number}&connect=claude` : SETUP_URL;
57
+ }
58
+ export function connectBody(login, runUrl, claudeUrl, setupLink = SETUP_URL) {
59
+ const options = [
60
+ ...(login ? [{ name: "ChatGPT", steps: `[Open ChatGPT](${login.url}) and enter \`${login.code}\`` }] : []),
61
+ ...(claudeUrl ? [{ name: "Claude", steps: `[Connect on mergelight.dev](${setupLink})` }] : []),
62
+ ];
63
+ const only = options.length === 1 ? options[0] : null;
64
+ const run = runUrl ? `[your own GitHub Actions run](${runUrl})` : "your own GitHub Actions run";
65
+ return [
66
+ SUMMARY_MARKER,
67
+ PENDING_MARKER,
68
+ connectData(login, claudeUrl),
69
+ `### 🔌 Connect ${only?.name ?? "your AI"} to start reviews`,
70
+ "",
71
+ ...(only ? [`${only.steps}. The review then starts by itself.`] : ["Pick one, and the review starts by itself.", "", ...options.map((o) => `- **${o.name}:** ${o.steps}`)]),
72
+ "",
73
+ "<details><summary>Something not working?</summary>",
74
+ "",
75
+ "- **The link or code ran out:** they last 15 minutes. Push a new commit to get new ones.",
76
+ ...(login ? [`- **OpenAI refuses the code:** turn on "Enable device code sign-in" in your [ChatGPT security settings](${CHATGPT_SECURITY_URL}), or ask your workspace admin to.`] : []),
77
+ "",
78
+ "</details>",
79
+ "",
80
+ `<sub>Reviews use the plan of whoever connects it, so only do this if you're setting up this repository. This comes from ${run}. The login is saved as this repository's secret and never reaches Mergelight.</sub>`,
81
+ ].join("\n");
82
+ }
83
+ export function installAppBody() {
84
+ return [
85
+ SUMMARY_MARKER,
86
+ PENDING_MARKER,
87
+ "### 🔌 One step left: install the Mergelight AI app",
88
+ "",
89
+ `[Install it on this repository](${INSTALL_URL}), or approve its updated permissions if it's already installed, then push a new commit. The app saves your ChatGPT login, so you only connect once.`,
90
+ ].join("\n");
91
+ }
92
+ export function connectFailedBody(reason, claudeOffered = false) {
93
+ return [
94
+ SUMMARY_MARKER,
95
+ FAILED_MARKER,
96
+ claudeOffered ? "### ⚠️ Mergelight couldn't connect your AI" : "### ⚠️ Mergelight couldn't connect to ChatGPT",
97
+ "",
98
+ reason,
99
+ "",
100
+ claudeOffered ? "Push a new commit to get a new link and code. You can also use an API key instead: https://mergelight.dev/setup" : "Push a new commit to try again. You can also use an OpenAI API key instead: https://mergelight.dev/setup",
101
+ ].join("\n");
102
+ }
103
+ export async function connect(deps) {
104
+ let output = "";
105
+ let shown = false;
106
+ let notPosted = false;
107
+ let showing = Promise.resolve();
108
+ // First, so its link is in the comment that shows the ChatGPT code.
109
+ const claude = deps.startClaude ? await deps.startClaude().catch(() => null) : null;
110
+ const show = (login) => {
111
+ shown = true;
112
+ // Nobody can finish a login whose code never reached the pull request, so don't wait for it.
113
+ showing = deps.showCode(login, claude?.url).catch(() => {
114
+ notPosted = true;
115
+ deps.stopLogin();
116
+ claude?.stop();
117
+ });
118
+ };
119
+ const codexLogin = deps.startLogin((text) => {
120
+ output += text;
121
+ if (shown)
122
+ return;
123
+ const login = parseDeviceLogin(output);
124
+ if (login)
125
+ show(login);
126
+ });
127
+ const claudeWait = claude ? claude.wait().catch(() => false) : Promise.resolve(false);
128
+ const winner = await new Promise((resolve, reject) => {
129
+ let code = null;
130
+ let claudeOver = !claude;
131
+ codexLogin.then((exit) => {
132
+ code = exit;
133
+ if (exit === 0)
134
+ resolve("chatgpt");
135
+ else if (claudeOver || notPosted)
136
+ resolve(exit);
137
+ // ChatGPT's device sign-in can be switched off. Claude's link still has to reach the pull request.
138
+ else if (!shown)
139
+ show(null);
140
+ }, reject);
141
+ claudeWait.then((ok) => {
142
+ claudeOver = true;
143
+ if (ok)
144
+ resolve("claude");
145
+ else if (code !== null)
146
+ resolve(code);
147
+ });
148
+ });
149
+ await showing;
150
+ if (winner === "claude")
151
+ deps.stopLogin();
152
+ else
153
+ claude?.stop();
154
+ if (notPosted) {
155
+ await deps.failed("Mergelight couldn't post the login code on this pull request, because GitHub refused or didn't answer.");
156
+ return 1;
157
+ }
158
+ if (winner === "chatgpt" || winner === "claude") {
159
+ await deps.connected(winner);
160
+ return 0;
161
+ }
162
+ await deps.failed(claude
163
+ ? "The ChatGPT code and the Claude link both ran out before anyone finished signing in."
164
+ : shown
165
+ ? `The code ran out, or OpenAI refused it. Check that "Enable device code sign-in" is on in your [ChatGPT security settings](${CHATGPT_SECURITY_URL}).`
166
+ : `Codex couldn't start a device login. "Enable device code sign-in" may be off in your [ChatGPT security settings](${CHATGPT_SECURITY_URL}), or for your workspace.`);
167
+ return 1;
168
+ }
169
+ /** Tells the pull request the app has to be installed first. There's nowhere to save a login without it. */
170
+ export async function askForApp(api, number, headSha) {
171
+ await upsertSummary(api, number, installAppBody());
172
+ await writeCheck(api, null, headSha, {
173
+ status: "completed",
174
+ conclusion: "neutral",
175
+ completed_at: new Date().toISOString(),
176
+ output: { title: "Install the Mergelight AI app", summary: `Install it, then push a new commit: ${INSTALL_URL}` },
177
+ });
178
+ }
179
+ /** The summary holds what earlier rounds found, so a login prompt on a reviewed pull request has to keep that. */
180
+ async function upsertSummary(api, number, body) {
181
+ const { summary } = await readPrReviewState(api, number);
182
+ if (summary)
183
+ await api.write("PATCH", `issues/comments/${summary.id}`, { body: withState(body, summary.body) });
184
+ else
185
+ await api.write("POST", `issues/${number}/comments`, { body });
186
+ }
187
+ export function withState(body, previous) {
188
+ const state = stateBlock(previous);
189
+ return state ? `${body}\n${state}` : body;
190
+ }
191
+ /** The real thing: Codex on this runner, and the pull request as the place to show the code. */
192
+ /** A repository set up before Claude support doesn't pass the login on, so connecting it would only ask again next time. */
193
+ async function workflowHasClaude(api, env) {
194
+ const [, path, ref] = (env.GITHUB_WORKFLOW_REF ?? "").match(/^[^/]+\/[^/]+\/(.+?)@(.+)$/) ?? [];
195
+ if (!path || !ref)
196
+ return false;
197
+ return api
198
+ .get(`contents/${path}?ref=${encodeURIComponent(env.GITHUB_SHA ?? ref)}`)
199
+ .then((f) => Buffer.from(f.content, "base64").toString("utf8").includes("claude-code-oauth-token"), () => false);
200
+ }
201
+ export function githubConnect(api, number, headSha, log, env = process.env) {
202
+ const runUrl = env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
203
+ let checkId = null;
204
+ let running = null;
205
+ let claudeOffered = false;
206
+ /**
207
+ * The app's token dies if the app is removed during the login, which can wait 15 minutes. The comment
208
+ * still has to change, or it keeps offering a dead code, and the workflow's own token can do that.
209
+ */
210
+ const updateSummary = async (body) => {
211
+ try {
212
+ await upsertSummary(api, number, body);
213
+ }
214
+ catch (err) {
215
+ const fallback = env.GITHUB_WORKFLOW_TOKEN;
216
+ if (!fallback || fallback === env.GH_TOKEN || !/Bad credentials|HTTP 401/i.test(err instanceof Error ? err.message : String(err)))
217
+ throw err;
218
+ log("GitHub refused the app's token, so this updates the comment with the workflow's token.");
219
+ env.GH_TOKEN = fallback;
220
+ await upsertSummary(api, number, body);
221
+ }
222
+ };
223
+ const finish = (title, summary) => writeCheck(api, checkId, headSha, { status: "completed", conclusion: "neutral", completed_at: new Date().toISOString(), output: { title, summary } }, log);
224
+ return {
225
+ stopLogin: () => running?.kill("SIGTERM"),
226
+ startClaude: async () => {
227
+ if (!(await workflowHasClaude(api, env)))
228
+ return null;
229
+ const signIn = await startClaudeSignIn();
230
+ if (!signIn) {
231
+ log("Claude's sign-in didn't start, so only ChatGPT is offered.");
232
+ return null;
233
+ }
234
+ const ref = claudeCodeRef(signIn.url);
235
+ if (!ref) {
236
+ log("Claude's sign-in link wasn't the expected shape, so only ChatGPT is offered.");
237
+ signIn.stop();
238
+ return null;
239
+ }
240
+ claudeOffered = true;
241
+ let over = false;
242
+ return {
243
+ url: signIn.url,
244
+ stop: () => {
245
+ over = true;
246
+ signIn.stop();
247
+ },
248
+ wait: async () => {
249
+ const code = await waitForPastedCode({ read: () => readCodeRef((path) => api.get(path), ref), stopped: () => over });
250
+ if (!code)
251
+ return false;
252
+ const token = await signIn.finish(code);
253
+ if (!token) {
254
+ log("Claude didn't accept that code.");
255
+ return false;
256
+ }
257
+ shareClaudeToken(token, env);
258
+ return true;
259
+ },
260
+ };
261
+ },
262
+ startLogin: (onOutput) => new Promise((resolve, reject) => {
263
+ const child = spawn("codex", ["login", "--device-auth"], { stdio: ["ignore", "pipe", "pipe"] });
264
+ running = child;
265
+ const timer = setTimeout(() => child.kill("SIGTERM"), LOGIN_TIMEOUT_MS);
266
+ child.stdout.setEncoding("utf8").on("data", onOutput);
267
+ child.stderr.setEncoding("utf8").on("data", onOutput);
268
+ child.on("error", (err) => {
269
+ clearTimeout(timer);
270
+ reject(err);
271
+ });
272
+ child.on("close", (code) => {
273
+ clearTimeout(timer);
274
+ resolve(code ?? 1);
275
+ });
276
+ }),
277
+ showCode: async (login, claudeUrl) => {
278
+ await upsertSummary(api, number, connectBody(login, runUrl, claudeUrl, claudeSetupLink(env.GITHUB_REPOSITORY, number)));
279
+ log("Posted the link and code on the pull request. Waiting for the login.");
280
+ // The comment is what people need. A check that can't be written shouldn't end a login that can still work.
281
+ try {
282
+ const run = await writeCheck(api, null, headSha, {
283
+ status: "in_progress",
284
+ started_at: new Date().toISOString(),
285
+ output: { title: claudeUrl ? "Waiting for you to connect your AI" : "Waiting for you to connect ChatGPT", summary: "Follow the steps in the pull request's Mergelight comment." },
286
+ }, log);
287
+ checkId = run?.id ?? null;
288
+ }
289
+ catch (err) {
290
+ log(`Couldn't show the waiting check: ${err instanceof Error ? err.message : String(err)}`);
291
+ }
292
+ },
293
+ connected: async (via = "chatgpt") => {
294
+ await updateSummary(pendingSummaryBody());
295
+ if (via === "claude") {
296
+ await api.write("POST", `issues/${number}/comments`, { body: "🔌 Reviews on this repository now use the Claude plan that confirmed the sign-in. If that isn't yours, remove the `CLAUDE_CODE_OAUTH_TOKEN` secret and push a commit to connect again." });
297
+ await finish("Connected to Claude", "Starting the review.");
298
+ log("Connected to Claude.");
299
+ return;
300
+ }
301
+ let auth = "";
302
+ try {
303
+ auth = readFileSync(join(env.CODEX_HOME ?? "", "auth.json"), "utf8");
304
+ }
305
+ catch {
306
+ // Without the file the note just doesn't name the account.
307
+ }
308
+ await api.write("POST", `issues/${number}/comments`, { body: connectedBody(connectedAccount(auth), env.SECRET_NAME || "CODEX_AUTH_JSON") });
309
+ await finish("Connected to ChatGPT", "Starting the review.");
310
+ log("Connected to ChatGPT.");
311
+ },
312
+ failed: async (reason) => {
313
+ // One failed write mustn't stop the other. Only the app that made a check can finish it.
314
+ await updateSummary(connectFailedBody(reason, claudeOffered)).catch((err) => log(`Couldn't update the comment: ${err instanceof Error ? err.message : String(err)}`));
315
+ await finish(CHECK_FAILED_TITLE, reason).catch((err) => log(`Couldn't finish the check: ${err instanceof Error ? err.message : String(err)}`));
316
+ log(reason);
317
+ },
318
+ };
319
+ }
package/dist/github.js ADDED
@@ -0,0 +1,65 @@
1
+ import { CommandError, runOk } from "./run.js";
2
+ /**
3
+ * Accepts a pull request URL, owner/repo#123, or a bare number together with
4
+ * a default repository ("owner/repo").
5
+ */
6
+ export function parsePrRef(ref, defaultRepo) {
7
+ const url = ref.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
8
+ if (url)
9
+ return { owner: url[1], repo: url[2], number: Number(url[3]) };
10
+ const short = ref.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/);
11
+ if (short)
12
+ return { owner: short[1], repo: short[2], number: Number(short[3]) };
13
+ const bare = ref.match(/^#?(\d+)$/);
14
+ if (bare && defaultRepo) {
15
+ const [owner, repo] = defaultRepo.split("/");
16
+ if (owner && repo)
17
+ return { owner, repo, number: Number(bare[1]) };
18
+ }
19
+ if (bare) {
20
+ throw new Error(`"${ref}" is only a number. Pass --repo owner/name, or run this inside a clone of the repository.`);
21
+ }
22
+ throw new Error(`Could not read "${ref}" as a pull request. Use a URL, owner/repo#123, or a number with --repo.`);
23
+ }
24
+ /** The repository of the current directory, if gh can tell. */
25
+ export async function currentRepo() {
26
+ try {
27
+ const { stdout } = await runOk("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
28
+ return stdout.trim() || undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** gh api, retried a few times when GitHub answers with a 5xx or the network drops. */
35
+ async function ghApi(path) {
36
+ for (let attempt = 1;; attempt++) {
37
+ try {
38
+ return (await runOk("gh", ["api", path])).stdout;
39
+ }
40
+ catch (err) {
41
+ const transient = err instanceof CommandError && /HTTP 5\d\d|timeout|connection reset|EOF/i.test(err.message);
42
+ if (!transient || attempt === 4)
43
+ throw err;
44
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
45
+ }
46
+ }
47
+ }
48
+ export async function fetchPullRequest(ref) {
49
+ const pull = JSON.parse(await ghApi(`repos/${ref.owner}/${ref.repo}/pulls/${ref.number}`));
50
+ return {
51
+ owner: ref.owner,
52
+ repo: ref.repo,
53
+ number: pull.number,
54
+ title: pull.title,
55
+ body: pull.body ?? "",
56
+ url: pull.html_url,
57
+ author: pull.user?.login ?? "unknown",
58
+ baseRef: pull.base.ref,
59
+ baseSha: pull.base.sha,
60
+ headSha: pull.head.sha,
61
+ additions: pull.additions,
62
+ deletions: pull.deletions,
63
+ changedFiles: pull.changed_files,
64
+ };
65
+ }
package/dist/init.js ADDED
@@ -0,0 +1,92 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { run } from "./run.js";
6
+ const DOCS = "https://github.com/mergelightai/mergelight/blob/main/docs/agents.md";
7
+ /** The name on npm. Plain "mergelight" was refused there for being too close to an older package. */
8
+ const PACKAGE = "@mergelight/cli";
9
+ /** An installed copy starts through npx, so it keeps working when the package moves. A clone runs its own file. */
10
+ export function serverCommand(cliPath) {
11
+ return /[\\/]node_modules[\\/]@mergelight[\\/]cli[\\/]/.test(cliPath) ? { command: "npx", args: ["-y", PACKAGE, "mcp"] } : { command: "node", args: [cliPath, "mcp"] };
12
+ }
13
+ async function has(deps, cmd) {
14
+ try {
15
+ return (await deps.run(cmd, ["--version"])).code === 0;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function addSkill(deps, skills) {
22
+ mkdirSync(skills, { recursive: true });
23
+ cpSync(deps.skillDir, join(skills, "mergelight"), { recursive: true });
24
+ }
25
+ const why = (r) => (r.stderr || r.stdout).trim().split("\n").at(-1)?.slice(0, 200) ?? "no reason given";
26
+ export async function init(deps) {
27
+ const server = serverCommand(deps.cliPath);
28
+ let added = 0;
29
+ if (await has(deps, "claude")) {
30
+ // Claude Code refuses a name that's already there, so the old entry goes first.
31
+ await deps.run("claude", ["mcp", "remove", "--scope", "user", "mergelight"]).catch(() => undefined);
32
+ const r = await deps.run("claude", ["mcp", "add", "--scope", "user", "mergelight", "--", server.command, ...server.args]);
33
+ if (r.code === 0) {
34
+ addSkill(deps, join(deps.home, ".claude", "skills"));
35
+ deps.log("✓ MCP server and skill added for Claude Code");
36
+ added++;
37
+ }
38
+ else
39
+ deps.log(`Claude Code: couldn't add the MCP server (${why(r)})`);
40
+ }
41
+ if (await has(deps, "codex")) {
42
+ const r = await deps.run("codex", ["mcp", "add", "mergelight", "--", server.command, ...server.args]);
43
+ if (r.code === 0) {
44
+ addSkill(deps, join(deps.home, ".agents", "skills"));
45
+ deps.log("✓ MCP server and skill added for Codex");
46
+ added++;
47
+ }
48
+ else
49
+ deps.log(`Codex: couldn't add the MCP server (${why(r)})`);
50
+ }
51
+ const cursor = join(deps.home, ".cursor");
52
+ if (existsSync(cursor)) {
53
+ const file = join(cursor, "mcp.json");
54
+ let config = {};
55
+ if (existsSync(file)) {
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
58
+ config = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
59
+ }
60
+ catch {
61
+ config = null;
62
+ }
63
+ }
64
+ if (config) {
65
+ const servers = config.mcpServers && typeof config.mcpServers === "object" ? config.mcpServers : {};
66
+ writeFileSync(file, `${JSON.stringify({ ...config, mcpServers: { ...servers, mergelight: { type: "stdio", ...server } } }, null, 2)}\n`);
67
+ addSkill(deps, join(cursor, "skills"));
68
+ deps.log("✓ MCP server and skill added for Cursor");
69
+ added++;
70
+ }
71
+ else
72
+ deps.log(`Cursor: ${file} isn't valid JSON, so it's left as it is. To add Mergelight by hand, see ${DOCS}`);
73
+ }
74
+ if (!added) {
75
+ deps.log(`No coding agent was set up. Mergelight looks for Claude Code, Codex and Cursor. To add it by hand, see ${DOCS}`);
76
+ return 1;
77
+ }
78
+ if (!(await has(deps, "gh")))
79
+ deps.log("The server reads pull requests with the GitHub CLI. Install it from https://cli.github.com and run: gh auth login");
80
+ deps.log('Now ask your agent: "Handle the Mergelight findings on this PR."');
81
+ return 0;
82
+ }
83
+ export function realInitDeps() {
84
+ const here = dirname(fileURLToPath(import.meta.url));
85
+ return {
86
+ home: homedir(),
87
+ cliPath: join(here, "cli.js"),
88
+ skillDir: join(here, "..", "skills", "mergelight"),
89
+ run: (cmd, args) => run(cmd, args, { timeoutMs: 60_000 }),
90
+ log: (line) => console.log(line),
91
+ };
92
+ }