mergestorm 0.3.13 → 0.3.16
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 +16 -17
- package/dist/api.d.ts +103 -6
- package/dist/api.js +195 -9
- package/dist/automation-catalog.d.ts +37 -0
- package/dist/automation-catalog.js +13 -0
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.js +3 -2
- package/dist/commands/branches.d.ts +6 -2
- package/dist/commands/branches.js +29 -14
- package/dist/commands/browse.d.ts +39 -0
- package/dist/commands/browse.js +252 -0
- package/dist/commands/credits.js +7 -10
- package/dist/commands/help.d.ts +5 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/jobs.d.ts +11 -2
- package/dist/commands/jobs.js +64 -15
- package/dist/commands/login.js +4 -3
- package/dist/commands/logout.js +2 -1
- package/dist/commands/pr.d.ts +22 -0
- package/dist/commands/pr.js +127 -0
- package/dist/commands/queue.d.ts +12 -0
- package/dist/commands/queue.js +160 -0
- package/dist/commands/registry.js +98 -21
- package/dist/commands/review-client.d.ts +13 -0
- package/dist/commands/review-client.js +110 -0
- package/dist/commands/review.d.ts +6 -0
- package/dist/commands/review.js +18 -7
- package/dist/commands/settings.d.ts +17 -0
- package/dist/commands/settings.js +82 -0
- package/dist/commands/skill.d.ts +20 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/stack.d.ts +57 -2
- package/dist/commands/stack.js +352 -87
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +30 -15
- package/dist/commands/whoami.d.ts +3 -1
- package/dist/commands/whoami.js +21 -7
- package/dist/errors.d.ts +1 -1
- package/dist/repo-identity.d.ts +1 -1
- package/dist/repo-identity.js +13 -2
- package/dist/shell.js +39 -31
- package/dist/stack-dto.d.ts +92 -1
- package/dist/stack-meta.d.ts +12 -0
- package/dist/stack-meta.js +32 -3
- package/dist/ui/ansi.d.ts +2 -0
- package/dist/ui/ansi.js +2 -0
- package/dist/ui/banner.d.ts +34 -2
- package/dist/ui/banner.js +166 -79
- package/dist/ui/findings.d.ts +2 -1
- package/dist/ui/findings.js +13 -7
- package/dist/ui/line-tabs.d.ts +27 -0
- package/dist/ui/line-tabs.js +170 -0
- package/dist/ui/logo.d.ts +10 -2
- package/dist/ui/logo.js +13 -9
- package/dist/ui/present.d.ts +10 -0
- package/dist/ui/present.js +13 -0
- package/dist/ui/prompt.d.ts +58 -1
- package/dist/ui/prompt.js +225 -42
- package/dist/ui/select.js +1 -1
- package/dist/ui/tabs.d.ts +125 -0
- package/dist/ui/tabs.js +480 -0
- package/dist/ui/usage.d.ts +4 -0
- package/dist/ui/usage.js +22 -2
- package/package.json +2 -1
- package/skill/SKILL.md +98 -0
- package/skill/mergestorm-pr-loop/SKILL.md +84 -0
|
@@ -210,6 +210,116 @@ export class ReviewPollTimeoutError extends Error {
|
|
|
210
210
|
this.lastRow = lastRow;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
export class PrReviewPollTimeoutError extends Error {
|
|
214
|
+
owner;
|
|
215
|
+
repo;
|
|
216
|
+
prNumber;
|
|
217
|
+
lastEnvelope;
|
|
218
|
+
constructor(owner, repo, prNumber, lastEnvelope) {
|
|
219
|
+
super(`Timed out waiting for PR review ${owner}/${repo}#${prNumber}`);
|
|
220
|
+
this.name = "PrReviewPollTimeoutError";
|
|
221
|
+
this.owner = owner;
|
|
222
|
+
this.repo = repo;
|
|
223
|
+
this.prNumber = prNumber;
|
|
224
|
+
this.lastEnvelope = lastEnvelope;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function shaMatches(actual, expected) {
|
|
228
|
+
const left = actual?.trim().toLowerCase() ?? "";
|
|
229
|
+
const right = expected.trim().toLowerCase();
|
|
230
|
+
return Boolean(left && right.length >= 7 && (left.startsWith(right) || right.startsWith(left)));
|
|
231
|
+
}
|
|
232
|
+
/** Poll the DB-only PR review endpoint until the requested pass is resting. */
|
|
233
|
+
export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {}) {
|
|
234
|
+
const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
|
|
235
|
+
const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
|
|
236
|
+
const now = opts.now ?? Date.now;
|
|
237
|
+
const wait = opts.sleep ?? sleep;
|
|
238
|
+
const startedAt = now();
|
|
239
|
+
const deadline = startedAt + timeoutMs;
|
|
240
|
+
const query = new URLSearchParams({
|
|
241
|
+
owner,
|
|
242
|
+
repo,
|
|
243
|
+
pr_number: String(prNumber),
|
|
244
|
+
});
|
|
245
|
+
if (opts.afterSha)
|
|
246
|
+
query.set("after_sha", opts.afterSha);
|
|
247
|
+
let transientFailures = 0;
|
|
248
|
+
let lastEnvelope = null;
|
|
249
|
+
const sleepBeforeRetry = async (retryAfterSeconds) => {
|
|
250
|
+
const waitMs = Math.min(transientRetryWaitMs({
|
|
251
|
+
retryAfterSeconds,
|
|
252
|
+
failureCount: transientFailures,
|
|
253
|
+
random: opts.random,
|
|
254
|
+
}), Math.max(0, deadline - now()));
|
|
255
|
+
if (waitMs > 0)
|
|
256
|
+
await wait(waitMs, opts.signal);
|
|
257
|
+
};
|
|
258
|
+
const sleepBeforePoll = async () => {
|
|
259
|
+
const waitMs = Math.min(stretchedPollIntervalMs(now() - startedAt, intervalMs), Math.max(0, deadline - now()));
|
|
260
|
+
if (waitMs > 0)
|
|
261
|
+
await wait(waitMs, opts.signal);
|
|
262
|
+
};
|
|
263
|
+
while (now() < deadline) {
|
|
264
|
+
let poll;
|
|
265
|
+
try {
|
|
266
|
+
poll = await apiFetch(cfg, `/api/v1/stacks/pr-review?${query.toString()}`, {
|
|
267
|
+
signal: opts.signal,
|
|
268
|
+
timeoutMs: Math.max(1, Math.min(30_000, deadline - now())),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (isTransientReviewPollError(err) &&
|
|
273
|
+
transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
274
|
+
transientFailures += 1;
|
|
275
|
+
opts.onTick?.("retry");
|
|
276
|
+
await sleepBeforeRetry();
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (isCommandErrorCode(err, "api_timeout")) {
|
|
280
|
+
if (opts.propagateTimeout)
|
|
281
|
+
throw err;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
if (isTransientReviewPollError(err)) {
|
|
285
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
286
|
+
throw new CommandError(`Poll failed: ${detail}`, REVIEW_EXIT.failed, "review_failed");
|
|
287
|
+
}
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
if (poll.status === 404) {
|
|
291
|
+
transientFailures = 0;
|
|
292
|
+
opts.onTick?.("progress");
|
|
293
|
+
await sleepBeforePoll();
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (poll.status !== 200) {
|
|
297
|
+
if (isTransientReviewPollStatus(poll.status) &&
|
|
298
|
+
transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
299
|
+
transientFailures += 1;
|
|
300
|
+
opts.onTick?.("retry");
|
|
301
|
+
await sleepBeforeRetry(poll.retryAfterSeconds);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (poll.status === 429) {
|
|
305
|
+
throw new CommandError(rateLimitedMessage(poll.retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds: poll.retryAfterSeconds });
|
|
306
|
+
}
|
|
307
|
+
throw new CommandError(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, REVIEW_EXIT.failed, "review_failed");
|
|
308
|
+
}
|
|
309
|
+
transientFailures = 0;
|
|
310
|
+
if (!poll.body || typeof poll.body !== "object") {
|
|
311
|
+
throw new CommandError("Poll failed: invalid PR review response", REVIEW_EXIT.failed, "review_failed");
|
|
312
|
+
}
|
|
313
|
+
lastEnvelope = poll.body;
|
|
314
|
+
const resting = (lastEnvelope.raw_status ?? lastEnvelope.status) !== "in_progress";
|
|
315
|
+
const matches = !opts.afterSha || shaMatches(lastEnvelope.head_sha, opts.afterSha);
|
|
316
|
+
if (resting && matches)
|
|
317
|
+
return lastEnvelope;
|
|
318
|
+
opts.onTick?.("progress");
|
|
319
|
+
await sleepBeforePoll();
|
|
320
|
+
}
|
|
321
|
+
throw new PrReviewPollTimeoutError(owner, repo, prNumber, lastEnvelope);
|
|
322
|
+
}
|
|
213
323
|
export async function pollReview(cfg, jobId, opts = {}) {
|
|
214
324
|
const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
|
|
215
325
|
const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
|
|
@@ -7,6 +7,12 @@ export declare const VORTEX_ROUTER_CAPS: {
|
|
|
7
7
|
readonly standard: 3;
|
|
8
8
|
readonly max: 5;
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* Pinnable L0 lanes only. Keep this list byte-identical to
|
|
12
|
+
* `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
|
|
13
|
+
* (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
|
|
14
|
+
* (phase-gated). Those L1 lanes are not local pins.
|
|
15
|
+
*/
|
|
10
16
|
export declare const VORTEX_SPECIALIST_IDS: readonly ["security", "performance", "architecture", "tests", "data", "api", "frontend"];
|
|
11
17
|
type VortexRouterMode = (typeof VORTEX_ROUTER_MODES)[number];
|
|
12
18
|
type VortexSpecialistId = (typeof VORTEX_SPECIALIST_IDS)[number];
|
package/dist/commands/review.js
CHANGED
|
@@ -3,7 +3,8 @@ import { loadConfig } from "../config.js";
|
|
|
3
3
|
import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
|
|
4
4
|
import { discoverTrunk } from "../git-stack.js";
|
|
5
5
|
import { ansi } from "../ui/ansi.js";
|
|
6
|
-
import {
|
|
6
|
+
import { formatFindingsLines } from "../ui/findings.js";
|
|
7
|
+
import { present } from "../ui/present.js";
|
|
7
8
|
import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
|
|
8
9
|
import { MAX_IDEMPOTENCY_KEY_CHARS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_THREAD_SLUG_RE, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollReview, submitReview, } from "./review-client.js";
|
|
9
10
|
export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
|
|
@@ -11,6 +12,12 @@ export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransi
|
|
|
11
12
|
export const VORTEX_ROUTER_MODES = ["off", "standard", "max", "manual"];
|
|
12
13
|
/** Standard/Max fleet caps; Manual is capped at Max. */
|
|
13
14
|
export const VORTEX_ROUTER_CAPS = { standard: 3, max: 5 };
|
|
15
|
+
/**
|
|
16
|
+
* Pinnable L0 lanes only. Keep this list byte-identical to
|
|
17
|
+
* `pinnableVortexSpecialistIds(VORTEX_SPECIALIST_IDS)` in review-findings
|
|
18
|
+
* (`cli-lockstep.test.ts`). Do not add `governance` (round-gated) or `seam`
|
|
19
|
+
* (phase-gated). Those L1 lanes are not local pins.
|
|
20
|
+
*/
|
|
14
21
|
export const VORTEX_SPECIALIST_IDS = [
|
|
15
22
|
"security",
|
|
16
23
|
"performance",
|
|
@@ -394,14 +401,17 @@ export async function cmdReview(args, opts = {}) {
|
|
|
394
401
|
printJson(row, { jobId });
|
|
395
402
|
return;
|
|
396
403
|
}
|
|
397
|
-
console.log(`\nVerdict: ${ansi.bold(row.verdict ?? "comment")}`);
|
|
398
404
|
const ran = row.specialists_run ?? row.findings?.specialists_run ?? [];
|
|
405
|
+
const result = [
|
|
406
|
+
`Verdict: ${ansi.bold(row.verdict ?? "comment")}`,
|
|
407
|
+
];
|
|
399
408
|
if (ran.length > 0)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
409
|
+
result.push(ansi.dim(`Specialists: ${ran.join(", ")}`));
|
|
410
|
+
if (row.summary)
|
|
411
|
+
result.push(row.summary);
|
|
412
|
+
result.push(...formatFindingsLines(row.findings, {
|
|
403
413
|
emptyMessage: true,
|
|
404
|
-
});
|
|
414
|
+
}));
|
|
405
415
|
if (interactive && meBefore) {
|
|
406
416
|
const meAfter = await getMe(cfg);
|
|
407
417
|
if (meAfter) {
|
|
@@ -409,10 +419,11 @@ export async function cmdReview(args, opts = {}) {
|
|
|
409
419
|
const afterLeft = meAfter.usage.standard.remaining ?? 0;
|
|
410
420
|
const used = Math.max(0, beforeLeft - afterLeft);
|
|
411
421
|
if (used > 0) {
|
|
412
|
-
|
|
422
|
+
result.push(ansi.dim(`${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
|
|
413
423
|
}
|
|
414
424
|
}
|
|
415
425
|
}
|
|
426
|
+
await present("Review", result);
|
|
416
427
|
}
|
|
417
428
|
catch (err) {
|
|
418
429
|
if (err instanceof DetachedError)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SettingsPatch, type SettingsResponse } from "../api.js";
|
|
2
|
+
/**
|
|
3
|
+
* CLI flag → Bearer settings key. on|off only. The connected flags are
|
|
4
|
+
* read-only on the API and deliberately have no flag here.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_review_unit_land_prs_enabled" | "vortex_seam_specialist_enabled" | "auto_land_default">;
|
|
7
|
+
export type SettingsArgs = {
|
|
8
|
+
json: boolean;
|
|
9
|
+
patch: SettingsPatch;
|
|
10
|
+
};
|
|
11
|
+
/** Parse `settings` argv. Accepts `--flag on|off` and `--flag=on|off`. */
|
|
12
|
+
export declare function parseSettingsArgs(args: string[]): SettingsArgs;
|
|
13
|
+
/** Static printout of the GET/PATCH response shape (rows match the Config tab). */
|
|
14
|
+
export declare function formatSettingsLines(settings: SettingsResponse): string[];
|
|
15
|
+
export declare function cmdSettings(args: string[], opts?: {
|
|
16
|
+
mode?: "oneshot" | "shell";
|
|
17
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getSettings, patchSettings, } from "../api.js";
|
|
2
|
+
import { BEARER_SETTINGS_FLAGS } from "../automation-catalog.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { CommandError } from "../errors.js";
|
|
5
|
+
import { present } from "../ui/present.js";
|
|
6
|
+
import { buildConfigRows, canBrowse, openTabsBrowser } from "./browse.js";
|
|
7
|
+
/**
|
|
8
|
+
* CLI flag → Bearer settings key. on|off only. The connected flags are
|
|
9
|
+
* read-only on the API and deliberately have no flag here.
|
|
10
|
+
*/
|
|
11
|
+
export const SETTINGS_FLAGS = BEARER_SETTINGS_FLAGS;
|
|
12
|
+
/** Parse `settings` argv. Accepts `--flag on|off` and `--flag=on|off`. */
|
|
13
|
+
export function parseSettingsArgs(args) {
|
|
14
|
+
let json = false;
|
|
15
|
+
const patch = {};
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (arg === "--json") {
|
|
19
|
+
json = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const eq = arg.indexOf("=");
|
|
23
|
+
const flag = eq >= 0 ? arg.slice(0, eq) : arg;
|
|
24
|
+
const key = SETTINGS_FLAGS[flag];
|
|
25
|
+
if (!key) {
|
|
26
|
+
throw new CommandError(`Unknown settings flag: ${flag}. See \`mergestorm --help\`.`, 2, "usage");
|
|
27
|
+
}
|
|
28
|
+
const value = eq >= 0 ? arg.slice(eq + 1) : args[++i];
|
|
29
|
+
if (value !== "on" && value !== "off") {
|
|
30
|
+
throw new CommandError(`${flag} takes on or off.`, 2, "usage");
|
|
31
|
+
}
|
|
32
|
+
patch[key] = value === "on";
|
|
33
|
+
}
|
|
34
|
+
return { json, patch };
|
|
35
|
+
}
|
|
36
|
+
/** Static printout of the GET/PATCH response shape (rows match the Config tab). */
|
|
37
|
+
export function formatSettingsLines(settings) {
|
|
38
|
+
const rows = buildConfigRows(settings);
|
|
39
|
+
const labelWidth = Math.max(...rows.map((r) => r.label.length));
|
|
40
|
+
return rows.map((row) => {
|
|
41
|
+
const value = row.writable
|
|
42
|
+
? row.value
|
|
43
|
+
? "on"
|
|
44
|
+
: "off"
|
|
45
|
+
: row.value
|
|
46
|
+
? "connected"
|
|
47
|
+
: "not connected";
|
|
48
|
+
return ` ${row.label.padEnd(labelWidth + 2)}${value}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function cmdSettings(args, opts = {}) {
|
|
52
|
+
const { json, patch } = parseSettingsArgs(args);
|
|
53
|
+
const hasPatch = Object.keys(patch).length > 0;
|
|
54
|
+
// Bare `settings` on a TTY: the tabbed browser, opened on Config.
|
|
55
|
+
if (!hasPatch && !json && canBrowse()) {
|
|
56
|
+
await openTabsBrowser("config");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const cfg = await loadConfig();
|
|
60
|
+
let settings;
|
|
61
|
+
if (hasPatch) {
|
|
62
|
+
settings = await patchSettings(patch, cfg);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const got = await getSettings(cfg);
|
|
66
|
+
if (!got) {
|
|
67
|
+
throw new CommandError("Settings are not available (offline or API too old). " +
|
|
68
|
+
"Try again or open https://mergestorm.ai/settings");
|
|
69
|
+
}
|
|
70
|
+
settings = got;
|
|
71
|
+
}
|
|
72
|
+
if (json) {
|
|
73
|
+
const text = JSON.stringify(settings, null, 2);
|
|
74
|
+
if (opts.mode === "shell") {
|
|
75
|
+
await present("Settings", text.split("\n"));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log(text);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await present("Settings", formatSettingsLines(settings));
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const SKILL_NAME = "mergestorm-review";
|
|
2
|
+
export declare const PR_LOOP_SKILL_NAME = "mergestorm-pr-loop";
|
|
3
|
+
export declare const SKILL_NAMES: readonly ["mergestorm-review", "mergestorm-pr-loop"];
|
|
4
|
+
export type SkillName = (typeof SKILL_NAMES)[number];
|
|
5
|
+
export declare const SKILL_USAGE = "usage:\n mergestorm skill install --claude|--cursor [--json]\n\n Copies mergestorm-review and mergestorm-pr-loop into this repo (git toplevel, else cwd):\n --claude .claude/skills/<name>/SKILL.md\n --cursor .cursor/skills/<name>/SKILL.md\n Pass both flags to write both trees. Overwrites existing copies.";
|
|
6
|
+
export type SkillTarget = "claude" | "cursor";
|
|
7
|
+
export type SkillInstallRequest = {
|
|
8
|
+
help: boolean;
|
|
9
|
+
json: boolean;
|
|
10
|
+
targets: SkillTarget[];
|
|
11
|
+
};
|
|
12
|
+
export declare function parseSkillArgs(args: string[]): SkillInstallRequest;
|
|
13
|
+
/** Prefer the git repo root so `mg skill install` from a subdir still lands in-project. */
|
|
14
|
+
export declare function skillInstallRoot(cwd?: string): string;
|
|
15
|
+
export declare function skillDestination(root: string, target: SkillTarget, name?: SkillName): string;
|
|
16
|
+
export declare function resolveBundledSkillPath(fromFile?: string, name?: SkillName): string;
|
|
17
|
+
export declare function writeSkillCopies(sourcePath: string, destinations: string[]): Promise<string[]>;
|
|
18
|
+
export declare function cmdSkill(args: string[], opts?: {
|
|
19
|
+
cwd?: string;
|
|
20
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CommandError, isCommandErrorCode } from "../errors.js";
|
|
6
|
+
import { gitTopLevel } from "../repo-identity.js";
|
|
7
|
+
import { present } from "../ui/present.js";
|
|
8
|
+
export const SKILL_NAME = "mergestorm-review";
|
|
9
|
+
export const PR_LOOP_SKILL_NAME = "mergestorm-pr-loop";
|
|
10
|
+
export const SKILL_NAMES = [SKILL_NAME, PR_LOOP_SKILL_NAME];
|
|
11
|
+
export const SKILL_USAGE = `usage:
|
|
12
|
+
mergestorm skill install --claude|--cursor [--json]
|
|
13
|
+
|
|
14
|
+
Copies mergestorm-review and mergestorm-pr-loop into this repo (git toplevel, else cwd):
|
|
15
|
+
--claude .claude/skills/<name>/SKILL.md
|
|
16
|
+
--cursor .cursor/skills/<name>/SKILL.md
|
|
17
|
+
Pass both flags to write both trees. Overwrites existing copies.`;
|
|
18
|
+
export function parseSkillArgs(args) {
|
|
19
|
+
const [sub, ...rest] = args;
|
|
20
|
+
if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
|
|
21
|
+
return { help: true, json: false, targets: [] };
|
|
22
|
+
}
|
|
23
|
+
if (sub !== "install") {
|
|
24
|
+
throw new CommandError(`${SKILL_USAGE}\nunknown skill subcommand: ${sub}`, 1, "usage");
|
|
25
|
+
}
|
|
26
|
+
let json = false;
|
|
27
|
+
const targets = [];
|
|
28
|
+
for (const arg of rest) {
|
|
29
|
+
if (arg === "--json") {
|
|
30
|
+
json = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (arg === "--claude") {
|
|
34
|
+
if (!targets.includes("claude"))
|
|
35
|
+
targets.push("claude");
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === "--cursor") {
|
|
39
|
+
if (!targets.includes("cursor"))
|
|
40
|
+
targets.push("cursor");
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg === "-h" || arg === "--help" || arg === "help") {
|
|
44
|
+
return { help: true, json: false, targets: [] };
|
|
45
|
+
}
|
|
46
|
+
throw new CommandError(`${SKILL_USAGE}\nunknown flag: ${arg}`, 1, "usage");
|
|
47
|
+
}
|
|
48
|
+
if (targets.length === 0) {
|
|
49
|
+
throw new CommandError(`${SKILL_USAGE}\npass --claude and/or --cursor`, 1, "usage");
|
|
50
|
+
}
|
|
51
|
+
return { help: false, json, targets };
|
|
52
|
+
}
|
|
53
|
+
/** Prefer the git repo root so `mg skill install` from a subdir still lands in-project. */
|
|
54
|
+
export function skillInstallRoot(cwd = process.cwd()) {
|
|
55
|
+
try {
|
|
56
|
+
return gitTopLevel(cwd);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (isCommandErrorCode(err, "not_a_repo"))
|
|
60
|
+
return path.resolve(cwd);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function skillDestination(root, target, name = SKILL_NAME) {
|
|
65
|
+
if (target === "claude") {
|
|
66
|
+
return path.join(root, ".claude", "skills", name, "SKILL.md");
|
|
67
|
+
}
|
|
68
|
+
return path.join(root, ".cursor", "skills", name, "SKILL.md");
|
|
69
|
+
}
|
|
70
|
+
export function resolveBundledSkillPath(fromFile = import.meta.url, name = SKILL_NAME) {
|
|
71
|
+
const here = path.dirname(fileURLToPath(fromFile));
|
|
72
|
+
const packageRoot = path.resolve(here, "..", "..");
|
|
73
|
+
// The published package ships the review skill at skill/SKILL.md; extra
|
|
74
|
+
// skills sit beside it in per-name directories. The monorepo keeps every
|
|
75
|
+
// skill under skills/<name>/SKILL.md.
|
|
76
|
+
const candidates = name === SKILL_NAME
|
|
77
|
+
? [
|
|
78
|
+
path.join(packageRoot, "skill", "SKILL.md"),
|
|
79
|
+
path.join(packageRoot, "..", "..", "skills", name, "SKILL.md"),
|
|
80
|
+
]
|
|
81
|
+
: [
|
|
82
|
+
path.join(packageRoot, "skill", name, "SKILL.md"),
|
|
83
|
+
path.join(packageRoot, "..", "..", "skills", name, "SKILL.md"),
|
|
84
|
+
];
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
if (existsSync(candidate))
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
throw new CommandError(`${name} skill is missing (looked in ${candidates.join(" and ")})`);
|
|
90
|
+
}
|
|
91
|
+
async function assertNoSymlink(dest) {
|
|
92
|
+
const parts = dest.split(path.sep);
|
|
93
|
+
const marker = parts.findIndex((p) => p === ".claude" || p === ".cursor");
|
|
94
|
+
const first = marker === -1 ? parts.length - 1 : marker;
|
|
95
|
+
for (let i = first; i < parts.length; i++) {
|
|
96
|
+
const candidate = parts.slice(0, i + 1).join(path.sep);
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = await lstat(candidate);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err.code === "ENOENT")
|
|
103
|
+
continue;
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
if (stat.isSymbolicLink()) {
|
|
107
|
+
throw new CommandError(`refusing to write skill through symlink: ${candidate}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export async function writeSkillCopies(sourcePath, destinations) {
|
|
112
|
+
const body = readFileSync(sourcePath, "utf8");
|
|
113
|
+
const written = [];
|
|
114
|
+
for (const dest of destinations) {
|
|
115
|
+
await assertNoSymlink(dest);
|
|
116
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
117
|
+
await writeFile(dest, body, "utf8");
|
|
118
|
+
written.push(dest);
|
|
119
|
+
}
|
|
120
|
+
return written;
|
|
121
|
+
}
|
|
122
|
+
export async function cmdSkill(args, opts = {}) {
|
|
123
|
+
const req = parseSkillArgs(args);
|
|
124
|
+
if (req.help) {
|
|
125
|
+
await present("Skill", SKILL_USAGE.split("\n"));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const root = skillInstallRoot(opts.cwd);
|
|
129
|
+
const sources = {};
|
|
130
|
+
const written = [];
|
|
131
|
+
for (const name of SKILL_NAMES) {
|
|
132
|
+
const source = resolveBundledSkillPath(undefined, name);
|
|
133
|
+
sources[name] = source;
|
|
134
|
+
const destinations = req.targets.map((t) => skillDestination(root, t, name));
|
|
135
|
+
written.push(...(await writeSkillCopies(source, destinations)));
|
|
136
|
+
}
|
|
137
|
+
if (req.json) {
|
|
138
|
+
await present("Skill", JSON.stringify({ names: SKILL_NAMES, sources, written }, null, 2).split("\n"));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await present("Skill", [
|
|
142
|
+
` Installed ${SKILL_NAMES.join(" and ")}`,
|
|
143
|
+
...written.map((dest) => ` ${dest}`),
|
|
144
|
+
]);
|
|
145
|
+
}
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { adoptStack, ensureUpperPark, listStacks, type StackDto } from "../api.js";
|
|
1
|
+
import { adoptStack, ensureUpperPark, listStacks, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
|
|
2
2
|
import { loadConfig } from "../config.js";
|
|
3
3
|
import { createPr, findOpenPrNumber } from "../gh.js";
|
|
4
4
|
import { parseGithubOriginRepo } from "../git.js";
|
|
5
|
-
import { branchExists, commitsAheadOf, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
|
|
5
|
+
import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty } from "../git-stack.js";
|
|
6
6
|
import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
7
|
+
import { gitTopLevel } from "../repo-identity.js";
|
|
7
8
|
import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
|
|
8
9
|
/** A registered stack layer that would become the parent of a new layer. */
|
|
9
10
|
export type RegisteredParentHit = {
|
|
@@ -28,27 +29,74 @@ export declare function parseAdoptTarget(args: string[]): {
|
|
|
28
29
|
repo: string;
|
|
29
30
|
prNumber: number;
|
|
30
31
|
};
|
|
32
|
+
/** Empty + populated bodies for the TTY `stack list` panel. */
|
|
33
|
+
export declare function buildStackListLines(stacks: StackDto[]): string[];
|
|
34
|
+
/** Short per-stack policy suffixes: Auto land when on, overrides when pinned. */
|
|
35
|
+
export declare function stackPolicyLabels(s: Pick<StackDto, "autoEnqueueWhenReady" | "autoReviewOverride" | "autoPatchOverride">): string[];
|
|
36
|
+
/** Per-open policy flags shared by create, submit, and adopt (`on|off` only). */
|
|
37
|
+
export type StackOpenPolicyFlags = {
|
|
38
|
+
autoLand?: boolean;
|
|
39
|
+
autoReview?: boolean;
|
|
40
|
+
autoPatch?: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** Wire policy for adopt from the parsed `on|off` flags; absent keys stay off the body. */
|
|
43
|
+
export declare function openPolicyPatch(flags: StackOpenPolicyFlags): StackPolicyPatch | undefined;
|
|
31
44
|
/** Parse `stack create` argv into options. Exported for tests. */
|
|
32
45
|
export declare function parseStackCreateArgs(args: string[]): {
|
|
33
46
|
name?: string;
|
|
34
47
|
onto?: string;
|
|
35
48
|
trunk?: string;
|
|
36
49
|
extend: boolean;
|
|
50
|
+
autoLand?: boolean;
|
|
51
|
+
autoReview?: boolean;
|
|
52
|
+
autoPatch?: boolean;
|
|
37
53
|
asJson: boolean;
|
|
38
54
|
};
|
|
39
55
|
/** Parse `stack submit` argv. Exported for tests. */
|
|
40
56
|
export declare function parseStackSubmitArgs(args: string[]): {
|
|
41
57
|
extend: boolean;
|
|
58
|
+
autoLand?: boolean;
|
|
59
|
+
autoReview?: boolean;
|
|
60
|
+
autoPatch?: boolean;
|
|
61
|
+
asJson: boolean;
|
|
62
|
+
};
|
|
63
|
+
export declare function parseStackAdoptArgs(args: string[]): {
|
|
64
|
+
owner: string;
|
|
65
|
+
repo: string;
|
|
66
|
+
prNumber: number;
|
|
67
|
+
autoLand?: boolean;
|
|
68
|
+
autoReview?: boolean;
|
|
69
|
+
autoPatch?: boolean;
|
|
70
|
+
asJson: boolean;
|
|
71
|
+
};
|
|
72
|
+
export type StackSetArgs = {
|
|
73
|
+
stackId: string;
|
|
74
|
+
/** At least one of these is present. */
|
|
75
|
+
autoLand?: boolean;
|
|
76
|
+
autoReview?: boolean | null;
|
|
77
|
+
autoPatch?: boolean | null;
|
|
42
78
|
asJson: boolean;
|
|
43
79
|
};
|
|
80
|
+
export declare function parseStackSetArgs(args: string[]): StackSetArgs;
|
|
44
81
|
export declare function parseStackResetArgs(args: string[]): void;
|
|
45
82
|
/** Injectable seams for unit tests. Production callers omit deps. */
|
|
46
83
|
export type StackCreateDeps = {
|
|
47
84
|
cwd?: string;
|
|
85
|
+
gitTopLevel?: typeof gitTopLevel;
|
|
86
|
+
worktreeDirty?: typeof worktreeDirty;
|
|
87
|
+
currentBranch?: typeof currentBranch;
|
|
88
|
+
branchExists?: typeof branchExists;
|
|
89
|
+
createBranchFromHead?: typeof createBranchFromHead;
|
|
90
|
+
deleteBranch?: typeof deleteBranch;
|
|
91
|
+
discoverTrunk?: typeof discoverTrunk;
|
|
92
|
+
defaultLayerBranchName?: typeof defaultLayerBranchName;
|
|
93
|
+
loadStackMeta?: typeof loadStackMeta;
|
|
94
|
+
saveStackMeta?: typeof saveStackMeta;
|
|
48
95
|
listStacks?: typeof listStacks;
|
|
49
96
|
loadConfig?: typeof loadConfig;
|
|
50
97
|
parseGithubOriginRepo?: typeof parseGithubOriginRepo;
|
|
51
98
|
};
|
|
99
|
+
export declare function cmdStackCreate(args: string[], deps?: StackCreateDeps): Promise<void>;
|
|
52
100
|
/** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
|
|
53
101
|
export type StackSubmitDeps = {
|
|
54
102
|
cwd?: string;
|
|
@@ -72,4 +120,11 @@ export type StackSubmitDeps = {
|
|
|
72
120
|
ensureUpperPark?: typeof ensureUpperPark;
|
|
73
121
|
};
|
|
74
122
|
export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
|
|
123
|
+
export type StackSetDeps = {
|
|
124
|
+
loadConfig?: typeof loadConfig;
|
|
125
|
+
setStackPolicy?: typeof setStackPolicy;
|
|
126
|
+
};
|
|
127
|
+
/** Human summary lines for a `stack set` write, one per flag. */
|
|
128
|
+
export declare function stackSetSummary(parsed: StackSetArgs): string[];
|
|
129
|
+
export declare function cmdStackSet(args: string[], deps?: StackSetDeps): Promise<void>;
|
|
75
130
|
export declare function cmdStack(args: string[]): Promise<void>;
|