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.
Files changed (66) hide show
  1. package/README.md +16 -17
  2. package/dist/api.d.ts +103 -6
  3. package/dist/api.js +195 -9
  4. package/dist/automation-catalog.d.ts +37 -0
  5. package/dist/automation-catalog.js +13 -0
  6. package/dist/client/index.d.ts +3 -2
  7. package/dist/client/index.js +3 -2
  8. package/dist/commands/branches.d.ts +6 -2
  9. package/dist/commands/branches.js +29 -14
  10. package/dist/commands/browse.d.ts +39 -0
  11. package/dist/commands/browse.js +252 -0
  12. package/dist/commands/credits.js +7 -10
  13. package/dist/commands/help.d.ts +5 -0
  14. package/dist/commands/help.js +93 -0
  15. package/dist/commands/jobs.d.ts +11 -2
  16. package/dist/commands/jobs.js +64 -15
  17. package/dist/commands/login.js +4 -3
  18. package/dist/commands/logout.js +2 -1
  19. package/dist/commands/pr.d.ts +22 -0
  20. package/dist/commands/pr.js +127 -0
  21. package/dist/commands/queue.d.ts +12 -0
  22. package/dist/commands/queue.js +160 -0
  23. package/dist/commands/registry.js +98 -21
  24. package/dist/commands/review-client.d.ts +13 -0
  25. package/dist/commands/review-client.js +110 -0
  26. package/dist/commands/review.d.ts +6 -0
  27. package/dist/commands/review.js +18 -7
  28. package/dist/commands/settings.d.ts +17 -0
  29. package/dist/commands/settings.js +82 -0
  30. package/dist/commands/skill.d.ts +20 -0
  31. package/dist/commands/skill.js +145 -0
  32. package/dist/commands/stack.d.ts +57 -2
  33. package/dist/commands/stack.js +352 -87
  34. package/dist/commands/status.d.ts +2 -0
  35. package/dist/commands/status.js +30 -15
  36. package/dist/commands/whoami.d.ts +3 -1
  37. package/dist/commands/whoami.js +21 -7
  38. package/dist/errors.d.ts +1 -1
  39. package/dist/repo-identity.d.ts +1 -1
  40. package/dist/repo-identity.js +13 -2
  41. package/dist/shell.js +39 -31
  42. package/dist/stack-dto.d.ts +92 -1
  43. package/dist/stack-meta.d.ts +12 -0
  44. package/dist/stack-meta.js +32 -3
  45. package/dist/ui/ansi.d.ts +2 -0
  46. package/dist/ui/ansi.js +2 -0
  47. package/dist/ui/banner.d.ts +34 -2
  48. package/dist/ui/banner.js +166 -79
  49. package/dist/ui/findings.d.ts +2 -1
  50. package/dist/ui/findings.js +13 -7
  51. package/dist/ui/line-tabs.d.ts +27 -0
  52. package/dist/ui/line-tabs.js +170 -0
  53. package/dist/ui/logo.d.ts +10 -2
  54. package/dist/ui/logo.js +13 -9
  55. package/dist/ui/present.d.ts +10 -0
  56. package/dist/ui/present.js +13 -0
  57. package/dist/ui/prompt.d.ts +58 -1
  58. package/dist/ui/prompt.js +225 -42
  59. package/dist/ui/select.js +1 -1
  60. package/dist/ui/tabs.d.ts +125 -0
  61. package/dist/ui/tabs.js +480 -0
  62. package/dist/ui/usage.d.ts +4 -0
  63. package/dist/ui/usage.js +22 -2
  64. package/package.json +2 -1
  65. package/skill/SKILL.md +98 -0
  66. 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];
@@ -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 { renderFindings } from "../ui/findings.js";
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
- console.log(ansi.dim(`Specialists: ${ran.join(", ")}`));
401
- console.log(row.summary ?? "");
402
- renderFindings(row.findings, {
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
- console.log(ansi.dim(`\n${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
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
+ }
@@ -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>;