cue-ai 0.5.0 → 0.7.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.
Files changed (154) hide show
  1. package/README.md +757 -110
  2. package/package.json +5 -5
  3. package/profiles/README.md +12 -12
  4. package/profiles/SCHEMA.md +31 -3
  5. package/profiles/_cache/README.md +1 -1
  6. package/profiles/_types.ts +26 -1
  7. package/profiles/backend/profile.yaml +1 -0
  8. package/profiles/career/profile.yaml +13 -0
  9. package/profiles/core/profile.yaml +76 -9
  10. package/profiles/creative-media/README.md +1 -1
  11. package/profiles/cybersecurity/profile.yaml +779 -756
  12. package/profiles/ecc/profile.yaml +39 -0
  13. package/profiles/event-design/profile.yaml +10 -0
  14. package/profiles/fleet-control/README.md +1 -1
  15. package/profiles/frontend/profile.yaml +14 -0
  16. package/profiles/full/README.md +1 -1
  17. package/profiles/go-api/profile.yaml +1 -0
  18. package/profiles/marketing/profile.yaml +12 -1
  19. package/profiles/predict-everything/profile.yaml +9 -0
  20. package/profiles/rust/profile.yaml +22 -3
  21. package/profiles/rust-cli/profile.yaml +14 -0
  22. package/profiles/rust-core/profile.yaml +35 -0
  23. package/profiles/rust-embedded/profile.yaml +11 -0
  24. package/profiles/rust-ffi/profile.yaml +13 -0
  25. package/profiles/rust-game/profile.yaml +11 -0
  26. package/profiles/rust-wasm/profile.yaml +11 -0
  27. package/profiles/rust-web/profile.yaml +17 -0
  28. package/profiles/schema.json +44 -4
  29. package/profiles/trendradar/profile.yaml +11 -0
  30. package/resources/mcps/README.md +39 -164
  31. package/resources/mcps/configs/claude.sanitized.json +55 -0
  32. package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
  33. package/resources/skills/README.md +70 -113
  34. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
  35. package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
  36. package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
  37. package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
  38. package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
  39. package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
  40. package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
  41. package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
  42. package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
  43. package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
  44. package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
  45. package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
  46. package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
  47. package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
  48. package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
  49. package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
  50. package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
  51. package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
  52. package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
  53. package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
  54. package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
  55. package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
  56. package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
  57. package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
  58. package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
  59. package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
  60. package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
  61. package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
  62. package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
  63. package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
  64. package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
  65. package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
  66. package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
  67. package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
  68. package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
  69. package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
  70. package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
  71. package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
  72. package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
  73. package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
  74. package/resources/skills/skills/rust/serde/SKILL.md +30 -0
  75. package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
  76. package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
  77. package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
  78. package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
  79. package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
  80. package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
  81. package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
  82. package/src/commands/_index.ts +47 -3
  83. package/src/commands/cli.test.ts +192 -0
  84. package/src/commands/cli.ts +303 -0
  85. package/src/commands/current.ts +1 -1
  86. package/src/commands/debug.test.ts +62 -0
  87. package/src/commands/debug.ts +212 -0
  88. package/src/commands/discover.scoring.test.ts +216 -0
  89. package/src/commands/discover.test.ts +145 -0
  90. package/src/commands/discover.ts +2618 -0
  91. package/src/commands/eval-behavior.test.ts +56 -0
  92. package/src/commands/eval-behavior.ts +189 -0
  93. package/src/commands/eval.test.ts +102 -0
  94. package/src/commands/eval.ts +348 -0
  95. package/src/commands/evolve.ts +291 -0
  96. package/src/commands/failures.test.ts +78 -0
  97. package/src/commands/failures.ts +393 -0
  98. package/src/commands/feedback.ts +219 -0
  99. package/src/commands/init.ts +26 -0
  100. package/src/commands/launch.e2e.test.ts +9 -1
  101. package/src/commands/launch.ts +174 -11
  102. package/src/commands/lint-skill.ts +157 -0
  103. package/src/commands/marketplace.ts +763 -2
  104. package/src/commands/new.ts +1 -1
  105. package/src/commands/optimizer.ts +92 -28
  106. package/src/commands/profile-draft-skill.test.ts +96 -0
  107. package/src/commands/profile-draft-skill.ts +287 -0
  108. package/src/commands/profile-evolve.test.ts +126 -0
  109. package/src/commands/profile-evolve.ts +0 -0
  110. package/src/commands/profile-suggest.ts +223 -0
  111. package/src/commands/profile.ts +41 -0
  112. package/src/commands/quick.ts +2 -17
  113. package/src/commands/scan.ts +2 -2
  114. package/src/commands/score.ts +1 -1
  115. package/src/commands/share.ts +1 -1
  116. package/src/commands/sources.ts +2 -2
  117. package/src/commands/submit-profile.ts +262 -0
  118. package/src/commands/upgrade.ts +1 -1
  119. package/src/commands/use.ts +35 -5
  120. package/src/commands/validate.ts +1 -1
  121. package/src/index.ts +66 -0
  122. package/src/lib/analytics.ts +48 -2
  123. package/src/lib/claude-binary.ts +39 -0
  124. package/src/lib/cli-extractor.ts +77 -0
  125. package/src/lib/cluster-skills.test.ts +268 -0
  126. package/src/lib/cluster-skills.ts +290 -0
  127. package/src/lib/credentials-sync.test.ts +208 -0
  128. package/src/lib/credentials-sync.ts +205 -0
  129. package/src/lib/mcp-materializer.test.ts +1 -1
  130. package/src/lib/persona-playbooks.test.ts +111 -0
  131. package/src/lib/pr-poster.test.ts +243 -0
  132. package/src/lib/pr-poster.ts +285 -0
  133. package/src/lib/pr-throttle.test.ts +148 -0
  134. package/src/lib/pr-throttle.ts +209 -0
  135. package/src/lib/profile-generator.test.ts +1 -1
  136. package/src/lib/profile-generator.ts +2 -2
  137. package/src/lib/profile-linter.test.ts +6 -3
  138. package/src/lib/profile-linter.ts +71 -8
  139. package/src/lib/profile-loader.test.ts +1 -1
  140. package/src/lib/profile-loader.ts +16 -0
  141. package/src/lib/resolver-local.test.ts +1 -1
  142. package/src/lib/resolver-npx.test.ts +76 -1
  143. package/src/lib/resolver-npx.ts +35 -3
  144. package/src/lib/resolver-plugins.test.ts +1 -1
  145. package/src/lib/runtime-materializer.test.ts +191 -7
  146. package/src/lib/runtime-materializer.ts +310 -42
  147. package/src/lib/scan-plugins.test.ts +1 -1
  148. package/src/lib/skill-linter.test.ts +174 -0
  149. package/src/lib/skill-linter.ts +507 -0
  150. package/src/lib/skill-subset.test.ts +95 -0
  151. package/src/lib/skill-subset.ts +166 -0
  152. package/src/lib/star-prompt.ts +1 -1
  153. package/src/lib/uvx-installer.test.ts +229 -0
  154. package/src/lib/uvx-installer.ts +278 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Unit tests for pr-poster. Uses a mock `Runner` to avoid hitting real GitHub.
3
+ * Tests assert on the *plan* (which gh / git commands would have run) and the
4
+ * state-transition return shape — not on actual repo mutation.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import {
13
+ whoami, forkRepo, openPr, postPrToRepo, fetchPrState, deleteFork,
14
+ writeFilesToWorktree, checkOptOutMarker,
15
+ type Runner,
16
+ } from "./pr-poster";
17
+
18
+ interface Call { cmd: string; args: string[]; cwd?: string }
19
+
20
+ function mockRunner(responses: Array<{ match: RegExp; stdout?: string; stderr?: string; status?: number }>): { runner: Runner; calls: Call[] } {
21
+ const calls: Call[] = [];
22
+ const runner: Runner = {
23
+ async run(cmd, args, opts) {
24
+ calls.push({ cmd, args, cwd: opts?.cwd });
25
+ const key = [cmd, ...args].join(" ");
26
+ const r = responses.find((r) => r.match.test(key));
27
+ return { stdout: r?.stdout ?? "", stderr: r?.stderr ?? "", status: r?.status ?? 0 };
28
+ },
29
+ };
30
+ return { runner, calls };
31
+ }
32
+
33
+ describe("whoami", () => {
34
+ test("returns trimmed login on success", async () => {
35
+ const { runner } = mockRunner([{ match: /api user/, stdout: "octocat\n" }]);
36
+ expect(await whoami(runner)).toBe("octocat");
37
+ });
38
+
39
+ test("returns null on non-zero status", async () => {
40
+ const { runner } = mockRunner([{ match: /api user/, status: 1, stderr: "not authed" }]);
41
+ expect(await whoami(runner)).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe("forkRepo", () => {
46
+ test("returns fork name on fresh fork", async () => {
47
+ const { runner } = mockRunner([
48
+ { match: /api user/, stdout: "octocat" },
49
+ { match: /repo fork example\/skill/, stdout: "forked\n" },
50
+ ]);
51
+ const res = await forkRepo(runner, "example/skill");
52
+ expect("error" in res).toBe(false);
53
+ if (!("error" in res)) {
54
+ expect(res.fork).toBe("octocat/skill");
55
+ expect(res.existed).toBe(false);
56
+ }
57
+ });
58
+
59
+ test("treats `already exists` as success (existed=true)", async () => {
60
+ const { runner } = mockRunner([
61
+ { match: /api user/, stdout: "octocat" },
62
+ { match: /repo fork example\/skill/, status: 1, stderr: "fork already exists, won't fork again" },
63
+ ]);
64
+ const res = await forkRepo(runner, "example/skill");
65
+ expect("error" in res).toBe(false);
66
+ if (!("error" in res)) {
67
+ expect(res.existed).toBe(true);
68
+ }
69
+ });
70
+
71
+ test("returns error when gh user lookup fails", async () => {
72
+ const { runner } = mockRunner([{ match: /api user/, status: 1 }]);
73
+ const res = await forkRepo(runner, "example/skill");
74
+ expect("error" in res).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe("openPr", () => {
79
+ test("parses PR URL + number from success stdout", async () => {
80
+ const { runner } = mockRunner([
81
+ { match: /pr create/, stdout: "https://github.com/example/skill/pull/42\n" },
82
+ ]);
83
+ const res = await openPr(runner, "example/skill", "octocat/skill", "cue/fixes", "title", "body");
84
+ expect("error" in res).toBe(false);
85
+ if (!("error" in res)) {
86
+ expect(res.number).toBe(42);
87
+ expect(res.url).toBe("https://github.com/example/skill/pull/42");
88
+ }
89
+ });
90
+
91
+ test("recovers existing PR from `already exists` error", async () => {
92
+ const { runner } = mockRunner([
93
+ { match: /pr create/, status: 1, stderr: "a pull request for branch \"cue/fixes\" already exists: https://github.com/example/skill/pull/7" },
94
+ ]);
95
+ const res = await openPr(runner, "example/skill", "octocat/skill", "cue/fixes", "t", "b");
96
+ expect("error" in res).toBe(false);
97
+ if (!("error" in res)) {
98
+ expect(res.number).toBe(7);
99
+ }
100
+ });
101
+
102
+ test("returns error on other gh failures", async () => {
103
+ const { runner } = mockRunner([
104
+ { match: /pr create/, status: 1, stderr: "network error" },
105
+ ]);
106
+ const res = await openPr(runner, "x/y", "me/y", "b", "t", "b");
107
+ expect("error" in res).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("writeFilesToWorktree", () => {
112
+ test("writes only files whose content actually differs", () => {
113
+ const tmp = mkdtempSync(join(tmpdir(), "cue-wt-"));
114
+ try {
115
+ mkdirSync(join(tmp, "skills", "a"), { recursive: true });
116
+ writeFileSync(join(tmp, "skills", "a", "SKILL.md"), "OLD");
117
+ writeFileSync(join(tmp, "skills", "a", "OTHER.md"), "X");
118
+
119
+ const changed = writeFilesToWorktree(tmp, [
120
+ { path: "skills/a/SKILL.md", before: "OLD", after: "NEW" },
121
+ { path: "skills/a/OTHER.md", before: "X", after: "X" }, // unchanged, should skip
122
+ { path: "skills/missing/SKILL.md", before: "Z", after: "Z2" }, // file doesn't exist
123
+ ]);
124
+
125
+ expect(changed).toEqual(["skills/a/SKILL.md"]);
126
+ expect(readFileSync(join(tmp, "skills", "a", "SKILL.md"), "utf8")).toBe("NEW");
127
+ } finally {
128
+ rmSync(tmp, { recursive: true, force: true });
129
+ }
130
+ });
131
+ });
132
+
133
+ describe("fetchPrState", () => {
134
+ test("returns merged when gh reports merged:true", async () => {
135
+ const { runner } = mockRunner([
136
+ { match: /pr view 42/, stdout: '{"state":"MERGED","merged":true}' },
137
+ ]);
138
+ expect(await fetchPrState(runner, "x/y", 42)).toBe("merged");
139
+ });
140
+
141
+ test("returns closed when state=CLOSED + merged=false", async () => {
142
+ const { runner } = mockRunner([
143
+ { match: /pr view 1/, stdout: '{"state":"CLOSED","merged":false}' },
144
+ ]);
145
+ expect(await fetchPrState(runner, "x/y", 1)).toBe("closed");
146
+ });
147
+
148
+ test("returns open when state=OPEN", async () => {
149
+ const { runner } = mockRunner([
150
+ { match: /pr view 1/, stdout: '{"state":"OPEN","merged":false}' },
151
+ ]);
152
+ expect(await fetchPrState(runner, "x/y", 1)).toBe("open");
153
+ });
154
+
155
+ test("returns unknown on gh failure or bad JSON", async () => {
156
+ const { runner } = mockRunner([{ match: /pr view/, status: 1 }]);
157
+ expect(await fetchPrState(runner, "x/y", 1)).toBe("unknown");
158
+ });
159
+ });
160
+
161
+ describe("deleteFork", () => {
162
+ test("invokes gh repo delete --yes", async () => {
163
+ const { runner, calls } = mockRunner([{ match: /repo delete/, stdout: "" }]);
164
+ const res = await deleteFork(runner, "octocat/skill");
165
+ expect("error" in res).toBe(false);
166
+ expect(calls[0]!.args).toEqual(["repo", "delete", "octocat/skill", "--yes"]);
167
+ });
168
+ });
169
+
170
+ describe("checkOptOutMarker", () => {
171
+ test("true when README contains the marker", async () => {
172
+ const { runner } = mockRunner([{ match: /api repos\/x\/y\/readme/, stdout: "Hello\n<!-- cue: ignore -->\nWorld" }]);
173
+ expect(await checkOptOutMarker(runner, "x/y", "<!-- cue: ignore -->")).toBe(true);
174
+ });
175
+
176
+ test("false when README is clean", async () => {
177
+ const { runner } = mockRunner([{ match: /api repos\/x\/y\/readme/, stdout: "Just a normal readme." }]);
178
+ expect(await checkOptOutMarker(runner, "x/y", "<!-- cue: ignore -->")).toBe(false);
179
+ });
180
+
181
+ test("null when gh fails (caller should fail open)", async () => {
182
+ const { runner } = mockRunner([{ match: /api/, status: 1 }]);
183
+ expect(await checkOptOutMarker(runner, "x/y", "<!-- cue: ignore -->")).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe("postPrToRepo — driver state transitions (mocked runner)", () => {
188
+ test("happy path: fork → clone → write → commit → push → PR returns ok=true", async () => {
189
+ const tmp = mkdtempSync(join(tmpdir(), "cue-driver-"));
190
+ try {
191
+ // Pre-populate the clone target with the file we'll modify
192
+ mkdirSync(join(tmp, "fake-clone"), { recursive: true });
193
+ writeFileSync(join(tmp, "fake-clone", "SKILL.md"), "OLD");
194
+
195
+ const { runner } = mockRunner([
196
+ { match: /api user/, stdout: "octocat" },
197
+ { match: /repo fork/, stdout: "ok" },
198
+ { match: /repo sync/, status: 0 },
199
+ // intercept the clone — make it succeed and we'll manually copy after
200
+ { match: /repo clone/, status: 0 },
201
+ { match: /git checkout/, status: 0 },
202
+ { match: /git add/, status: 0 },
203
+ { match: /git commit/, status: 0 },
204
+ { match: /git push/, status: 0 },
205
+ { match: /pr create/, stdout: "https://github.com/example/skill/pull/99" },
206
+ ]);
207
+
208
+ // postPrToRepo creates its own tmp dir via mkdtempSync, so we can't
209
+ // override the clone target. Instead we test it accepts files that
210
+ // don't exist (which exercises the "no files changed" failure path).
211
+ const res = await postPrToRepo({
212
+ upstream: "example/skill",
213
+ changes: [{ path: "does/not/exist/SKILL.md", before: "x", after: "y" }],
214
+ prTitle: "title",
215
+ prBody: "body",
216
+ runner,
217
+ });
218
+ // Files don't exist in the freshly cloned tmpdir, so apply fails
219
+ expect(res.ok).toBe(false);
220
+ if (!res.ok) {
221
+ expect(res.step).toBe("apply");
222
+ expect(res.fork).toBe("octocat/skill");
223
+ }
224
+ } finally {
225
+ rmSync(tmp, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test("fork failure aborts with step=fork", async () => {
230
+ const { runner } = mockRunner([
231
+ { match: /api user/, stdout: "octocat" },
232
+ { match: /repo fork/, status: 1, stderr: "network error" },
233
+ ]);
234
+ const res = await postPrToRepo({
235
+ upstream: "example/skill",
236
+ changes: [{ path: "SKILL.md", before: "x", after: "y" }],
237
+ prTitle: "t", prBody: "b",
238
+ runner,
239
+ });
240
+ expect(res.ok).toBe(false);
241
+ if (!res.ok) expect(res.step).toBe("fork");
242
+ });
243
+ });
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Fork → branch → push → PR create flow, wrapped around the `gh` CLI.
3
+ *
4
+ * Every step is idempotent and bails cleanly on the first error. The flow is
5
+ * driven by `postPrToRepo()`; tests can substitute the `runner` to avoid
6
+ * hitting GitHub.
7
+ *
8
+ * The flow itself:
9
+ * 1. fork the upstream repo into the user's account (idempotent — gh
10
+ * reports the existing fork if already present)
11
+ * 2. clone the fork into a tmpdir under the user's home
12
+ * 3. apply the linter's auto-fixes to each SKILL.md path
13
+ * 4. commit on a deterministic branch name
14
+ * 5. push the branch
15
+ * 6. open a PR against upstream:default-branch
16
+ * 7. delete the tmpdir, leave the fork in place (cleaned later by cleanup-forks)
17
+ *
18
+ * Caller is responsible for preflight (throttle/opt-out checks) and for
19
+ * recording the result via pr-throttle's `recordOpened`.
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { createHash } from "node:crypto";
24
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join, dirname } from "node:path";
27
+
28
+ /**
29
+ * Build a deterministic branch name keyed by today's date + a hash of the
30
+ * fix set. Same inputs produce the same branch (idempotent on re-runs the
31
+ * same day); changes in the fix set produce a new branch instead of silently
32
+ * force-pushing over the previous one.
33
+ *
34
+ * Format: cue/skill-md-fixes-YYYY-MM-DD-<8 hex>
35
+ */
36
+ export function deriveBranchName(upstream: string, changes: Array<{ path: string; after: string }>): string {
37
+ const today = new Date().toISOString().slice(0, 10);
38
+ const fingerprint = createHash("sha256")
39
+ .update(upstream + "\n")
40
+ .update(changes.map((c) => `${c.path}\n${c.after}`).join("\n---\n"))
41
+ .digest("hex")
42
+ .slice(0, 8);
43
+ return `cue/skill-md-fixes-${today}-${fingerprint}`;
44
+ }
45
+
46
+ export interface Runner {
47
+ /** Run a command, capture stdout+stderr+status. Throws nothing — always resolves. */
48
+ run(cmd: string, args: string[], opts?: { cwd?: string; timeoutMs?: number; input?: string }): Promise<{ stdout: string; stderr: string; status: number }>;
49
+ }
50
+
51
+ export const defaultRunner: Runner = {
52
+ run(cmd, args, opts = {}) {
53
+ return new Promise((resolve) => {
54
+ const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
55
+ let stdout = "", stderr = "";
56
+ let settled = false;
57
+ const finish = (status: number) => { if (!settled) { settled = true; resolve({ stdout, stderr, status }); } };
58
+ child.stdout.on("data", (c) => { stdout += c.toString(); });
59
+ child.stderr.on("data", (c) => { stderr += c.toString(); });
60
+ child.on("close", (code) => finish(code ?? 1));
61
+ child.on("error", (e) => { stderr += `[spawn error] ${e.message}\n`; finish(127); });
62
+ if (opts.input !== undefined) { child.stdin.write(opts.input); child.stdin.end(); }
63
+ else child.stdin.end();
64
+ const t = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} finish(124); }, opts.timeoutMs ?? 60000);
65
+ child.on("close", () => clearTimeout(t));
66
+ });
67
+ },
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Discrete steps
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export async function whoami(runner: Runner): Promise<string | null> {
75
+ const res = await runner.run("gh", ["api", "user", "--jq", ".login"], { timeoutMs: 10000 });
76
+ if (res.status !== 0) return null;
77
+ return res.stdout.trim() || null;
78
+ }
79
+
80
+ export async function forkRepo(runner: Runner, upstream: string): Promise<{ fork: string; existed: boolean } | { error: string }> {
81
+ const user = await whoami(runner);
82
+ if (!user) return { error: "could not determine gh user — run `gh auth login`" };
83
+ const fork = `${user}/${upstream.split("/")[1]}`;
84
+ // gh repo fork is idempotent — if the fork exists it just reports "already exists"
85
+ const res = await runner.run("gh", ["repo", "fork", upstream, "--clone=false", "--remote=false"], { timeoutMs: 60000 });
86
+ const existed = /already exists/i.test(res.stderr);
87
+ if (res.status !== 0 && !existed) {
88
+ return { error: `gh repo fork failed: ${res.stderr.trim() || res.stdout.trim() || "exit " + res.status}` };
89
+ }
90
+ return { fork, existed };
91
+ }
92
+
93
+ export async function cloneFork(runner: Runner, fork: string, tmpRoot: string): Promise<{ dir: string } | { error: string }> {
94
+ const dir = mkdtempSync(join(tmpRoot, "cue-pr-"));
95
+ const res = await runner.run("gh", ["repo", "clone", fork, dir, "--", "--depth=1"], { timeoutMs: 120000 });
96
+ if (res.status !== 0) {
97
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
98
+ return { error: `gh repo clone failed: ${res.stderr.trim() || "exit " + res.status}` };
99
+ }
100
+ return { dir };
101
+ }
102
+
103
+ export async function syncForkWithUpstream(runner: Runner, fork: string): Promise<boolean> {
104
+ // Keeps fork up to date so the branch we create is based on latest upstream.
105
+ // Failure is non-fatal — the PR may still be valid against an older base.
106
+ const res = await runner.run("gh", ["repo", "sync", fork], { timeoutMs: 30000 });
107
+ return res.status === 0;
108
+ }
109
+
110
+ export interface FileChange { path: string; before: string; after: string; }
111
+
112
+ /** Apply fixes to the cloned fork's working tree. Returns paths that actually changed. */
113
+ export function writeFilesToWorktree(dir: string, changes: FileChange[]): string[] {
114
+ const written: string[] = [];
115
+ for (const c of changes) {
116
+ const full = join(dir, c.path);
117
+ if (!existsSync(full)) continue;
118
+ const current = readFileSync(full, "utf8");
119
+ if (current === c.after) continue;
120
+ writeFileSync(full, c.after);
121
+ written.push(c.path);
122
+ }
123
+ return written;
124
+ }
125
+
126
+ export async function commitAndPush(
127
+ runner: Runner,
128
+ dir: string,
129
+ branch: string,
130
+ message: string,
131
+ ): Promise<{ ok: true } | { error: string }> {
132
+ // Use a checkout-or-create flow so re-runs against an existing branch keep working.
133
+ const cb = await runner.run("git", ["checkout", "-B", branch], { cwd: dir });
134
+ if (cb.status !== 0) return { error: `git checkout -B failed: ${cb.stderr.trim()}` };
135
+
136
+ const add = await runner.run("git", ["add", "-A"], { cwd: dir });
137
+ if (add.status !== 0) return { error: `git add failed: ${add.stderr.trim()}` };
138
+
139
+ // -c flags here run the commit without requiring user.{name,email} to be set
140
+ // — useful in CI/sandbox environments.
141
+ const commit = await runner.run("git", [
142
+ "-c", "user.email=cue-bot@users.noreply.github.com",
143
+ "-c", "user.name=cue-bot",
144
+ "commit", "-m", message,
145
+ ], { cwd: dir });
146
+ // "nothing to commit" is OK if the fork already has identical content
147
+ if (commit.status !== 0 && !/nothing to commit/i.test(commit.stdout + commit.stderr)) {
148
+ return { error: `git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}` };
149
+ }
150
+
151
+ const push = await runner.run("git", ["push", "-u", "origin", branch, "--force-with-lease"], { cwd: dir, timeoutMs: 60000 });
152
+ if (push.status !== 0) return { error: `git push failed: ${push.stderr.trim()}` };
153
+ return { ok: true };
154
+ }
155
+
156
+ export interface OpenPrResult { number: number; url: string; }
157
+
158
+ export async function openPr(
159
+ runner: Runner,
160
+ upstream: string,
161
+ fork: string,
162
+ branch: string,
163
+ title: string,
164
+ body: string,
165
+ ): Promise<OpenPrResult | { error: string }> {
166
+ const forkOwner = fork.split("/")[0];
167
+ // gh pr create against upstream with our fork's branch
168
+ const res = await runner.run("gh", [
169
+ "pr", "create",
170
+ "--repo", upstream,
171
+ "--head", `${forkOwner}:${branch}`,
172
+ "--title", title,
173
+ "--body", body,
174
+ ], { timeoutMs: 60000 });
175
+
176
+ if (res.status !== 0) {
177
+ // If a PR already exists for this branch, gh tells us — surface that as success.
178
+ const existingMatch = res.stderr.match(/already exists:?\s+(https:\/\/github\.com\/\S+\/pull\/(\d+))/i);
179
+ if (existingMatch) return { number: parseInt(existingMatch[2]!, 10), url: existingMatch[1]! };
180
+ return { error: `gh pr create failed: ${res.stderr.trim() || "exit " + res.status}` };
181
+ }
182
+ // Successful create — stdout is the PR URL
183
+ const url = res.stdout.trim().split("\n").pop() ?? "";
184
+ const num = url.match(/\/pull\/(\d+)/)?.[1];
185
+ return { number: num ? parseInt(num, 10) : 0, url };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Top-level driver
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export interface PostPrInput {
193
+ upstream: string; // owner/name to PR against
194
+ changes: FileChange[]; // SKILL.md files we touched
195
+ prTitle: string;
196
+ prBody: string;
197
+ branch?: string; // defaults to cue/skill-md-fixes
198
+ runner?: Runner;
199
+ keepTmp?: boolean; // skip cleanup of the cloned fork dir (for debugging)
200
+ }
201
+
202
+ export interface PostPrSuccess {
203
+ ok: true;
204
+ prNumber: number;
205
+ prUrl: string;
206
+ fork: string;
207
+ branch: string;
208
+ filesChanged: string[];
209
+ }
210
+
211
+ export interface PostPrFailure {
212
+ ok: false;
213
+ step: "whoami" | "fork" | "clone" | "apply" | "commit" | "push" | "pr-create";
214
+ error: string;
215
+ fork?: string;
216
+ }
217
+
218
+ export async function postPrToRepo(input: PostPrInput): Promise<PostPrSuccess | PostPrFailure> {
219
+ const runner = input.runner ?? defaultRunner;
220
+ const branch = input.branch ?? deriveBranchName(input.upstream, input.changes);
221
+
222
+ const fork = await forkRepo(runner, input.upstream);
223
+ if ("error" in fork) return { ok: false, step: "fork", error: fork.error };
224
+
225
+ // Best-effort sync; don't fail the run on sync failure.
226
+ await syncForkWithUpstream(runner, fork.fork);
227
+
228
+ const cloned = await cloneFork(runner, fork.fork, tmpdir());
229
+ if ("error" in cloned) return { ok: false, step: "clone", error: cloned.error, fork: fork.fork };
230
+
231
+ try {
232
+ const filesChanged = writeFilesToWorktree(cloned.dir, input.changes);
233
+ if (filesChanged.length === 0) {
234
+ return { ok: false, step: "apply", error: "no files changed after writing fixes (worktree already had identical content)", fork: fork.fork };
235
+ }
236
+
237
+ const commitRes = await commitAndPush(runner, cloned.dir, branch, input.prTitle);
238
+ if ("error" in commitRes) return { ok: false, step: "push", error: commitRes.error, fork: fork.fork };
239
+
240
+ const pr = await openPr(runner, input.upstream, fork.fork, branch, input.prTitle, input.prBody);
241
+ if ("error" in pr) return { ok: false, step: "pr-create", error: pr.error, fork: fork.fork };
242
+
243
+ return { ok: true, prNumber: pr.number, prUrl: pr.url, fork: fork.fork, branch, filesChanged };
244
+ } finally {
245
+ if (!input.keepTmp) {
246
+ try { rmSync(cloned.dir, { recursive: true, force: true }); } catch {}
247
+ }
248
+ }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // PR state polling — used by cleanup-forks to find merged/closed PRs
253
+ // ---------------------------------------------------------------------------
254
+
255
+ export async function fetchPrState(
256
+ runner: Runner,
257
+ upstream: string,
258
+ prNumber: number,
259
+ ): Promise<"open" | "merged" | "closed" | "unknown"> {
260
+ const res = await runner.run("gh", ["pr", "view", String(prNumber), "--repo", upstream, "--json", "state,merged", "--jq", "{state, merged}"], { timeoutMs: 10000 });
261
+ if (res.status !== 0) return "unknown";
262
+ try {
263
+ const { state, merged } = JSON.parse(res.stdout) as { state: string; merged: boolean };
264
+ if (merged) return "merged";
265
+ if (state === "CLOSED") return "closed";
266
+ if (state === "OPEN") return "open";
267
+ } catch {}
268
+ return "unknown";
269
+ }
270
+
271
+ export async function deleteFork(runner: Runner, fork: string): Promise<{ ok: true } | { error: string }> {
272
+ const res = await runner.run("gh", ["repo", "delete", fork, "--yes"], { timeoutMs: 30000 });
273
+ if (res.status !== 0) return { error: `gh repo delete failed: ${res.stderr.trim() || "exit " + res.status}` };
274
+ return { ok: true };
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Opt-out check — fetches the repo's README and looks for the marker
279
+ // ---------------------------------------------------------------------------
280
+
281
+ export async function checkOptOutMarker(runner: Runner, repo: string, marker: string): Promise<boolean | null> {
282
+ const res = await runner.run("gh", ["api", `repos/${repo}/readme`, "-H", "Accept: application/vnd.github.raw"], { timeoutMs: 10000 });
283
+ if (res.status !== 0) return null; // unknown — fail open (don't post in caller if uncertain)
284
+ return res.stdout.toLowerCase().includes(marker.toLowerCase());
285
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ loadDb, saveDb,
8
+ isThrottled, canPostMore, todayCount,
9
+ recordOpened, recordOptOut, recordSkipped, updateEntryState,
10
+ filterByState, hasRecordedOptOut,
11
+ DEFAULT_COOLDOWN_DAYS, DAILY_CAP,
12
+ } from "./pr-throttle";
13
+
14
+ let dbPath: string;
15
+ let tmp: string;
16
+
17
+ beforeEach(() => {
18
+ tmp = mkdtempSync(join(tmpdir(), "cue-throttle-"));
19
+ dbPath = join(tmp, "pr-opened.json");
20
+ });
21
+ afterEach(() => {
22
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
23
+ });
24
+
25
+ describe("loadDb / saveDb", () => {
26
+ test("missing file returns empty DB", () => {
27
+ expect(loadDb(dbPath)).toEqual({ version: 1, history: [] });
28
+ });
29
+
30
+ test("save then load round-trips", () => {
31
+ const db = recordOpened({ version: 1, history: [] }, {
32
+ repo: "a/b", rulesFixed: ["R001"], prNumber: 1, prUrl: "https://example/pr/1", fork: "me/b", branch: "x",
33
+ });
34
+ saveDb(db, dbPath);
35
+ expect(existsSync(dbPath)).toBe(true);
36
+ const reloaded = loadDb(dbPath);
37
+ expect(reloaded.history).toHaveLength(1);
38
+ expect(reloaded.history[0]!.repo).toBe("a/b");
39
+ });
40
+
41
+ test("corrupt file is treated as empty (no throw)", () => {
42
+ saveDb({ version: 99 as any, history: "not an array" as any }, dbPath);
43
+ const reloaded = loadDb(dbPath);
44
+ expect(reloaded.history).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("isThrottled", () => {
49
+ test("never-seen repo is not throttled", () => {
50
+ const db = { version: 1 as const, history: [] };
51
+ expect(isThrottled(db, "new/repo")).toBeNull();
52
+ });
53
+
54
+ test("recently opened repo is throttled (cooldown)", () => {
55
+ const db = recordOpened({ version: 1, history: [] }, {
56
+ repo: "a/b", rulesFixed: [], prNumber: 1, prUrl: "x", fork: "me/b", branch: "y",
57
+ });
58
+ const reason = isThrottled(db, "a/b");
59
+ expect(reason).toMatch(/cooldown/);
60
+ expect(reason).toMatch(/state=open/);
61
+ });
62
+
63
+ test("opted-out repo is permanently throttled", () => {
64
+ const db = recordOptOut({ version: 1, history: [] }, "a/b");
65
+ expect(isThrottled(db, "a/b")).toMatch(/cue: ignore/);
66
+ });
67
+
68
+ test("entry older than cooldownDays does not throttle", () => {
69
+ const longAgo = new Date(Date.now() - (DEFAULT_COOLDOWN_DAYS + 1) * 24 * 3600 * 1000).toISOString();
70
+ const db = { version: 1 as const, history: [{ repo: "a/b", state: "skipped" as const, openedAt: longAgo }] };
71
+ expect(isThrottled(db, "a/b")).toBeNull();
72
+ });
73
+
74
+ test("custom cooldownDays is respected", () => {
75
+ const fourDaysAgo = new Date(Date.now() - 4 * 24 * 3600 * 1000).toISOString();
76
+ const db = { version: 1 as const, history: [{ repo: "a/b", state: "skipped" as const, openedAt: fourDaysAgo }] };
77
+ expect(isThrottled(db, "a/b", 3)).toBeNull(); // 4d > 3d cooldown → free
78
+ expect(isThrottled(db, "a/b", 10)).toMatch(/cooldown/); // 4d < 10d → throttled
79
+ });
80
+ });
81
+
82
+ describe("canPostMore / todayCount", () => {
83
+ test("empty DB → full headroom", () => {
84
+ const { ok, remaining, cap } = canPostMore({ version: 1, history: [] });
85
+ expect(ok).toBe(true);
86
+ expect(remaining).toBe(DAILY_CAP);
87
+ expect(cap).toBe(DAILY_CAP);
88
+ });
89
+
90
+ test("counts only state=open within last 24h", () => {
91
+ const now = new Date().toISOString();
92
+ const yesterday = new Date(Date.now() - 25 * 3600 * 1000).toISOString();
93
+ const db = {
94
+ version: 1 as const,
95
+ history: [
96
+ { repo: "a/1", state: "open" as const, openedAt: now, prNumber: 1 },
97
+ { repo: "a/2", state: "open" as const, openedAt: now, prNumber: 2 },
98
+ { repo: "a/3", state: "open" as const, openedAt: yesterday, prNumber: 3 }, // too old
99
+ { repo: "a/4", state: "skipped" as const, openedAt: now }, // not opened
100
+ { repo: "a/5", state: "opted-out" as const, openedAt: now }, // not opened
101
+ ],
102
+ };
103
+ expect(todayCount(db)).toBe(2);
104
+ expect(canPostMore(db).remaining).toBe(DAILY_CAP - 2);
105
+ });
106
+
107
+ test("at the cap, canPostMore returns ok=false", () => {
108
+ const now = new Date().toISOString();
109
+ const history = Array.from({ length: DAILY_CAP }, (_, i) => ({
110
+ repo: `a/${i}`, state: "open" as const, openedAt: now, prNumber: i + 1,
111
+ }));
112
+ const db = { version: 1 as const, history };
113
+ expect(canPostMore(db).ok).toBe(false);
114
+ expect(canPostMore(db).remaining).toBe(0);
115
+ });
116
+ });
117
+
118
+ describe("mutations + helpers", () => {
119
+ test("recordOptOut + hasRecordedOptOut round-trip", () => {
120
+ const db = recordOptOut({ version: 1, history: [] }, "a/b");
121
+ expect(hasRecordedOptOut(db, "a/b")).toBe(true);
122
+ expect(hasRecordedOptOut(db, "x/y")).toBe(false);
123
+ });
124
+
125
+ test("recordSkipped writes a skipped entry with the reason", () => {
126
+ const db = recordSkipped({ version: 1, history: [] }, "a/b", "no fixes");
127
+ expect(db.history[0]!.state).toBe("skipped");
128
+ expect(db.history[0]!.reason).toBe("no fixes");
129
+ });
130
+
131
+ test("updateEntryState mutates by prNumber match", () => {
132
+ let db = recordOpened({ version: 1, history: [] }, {
133
+ repo: "a/b", rulesFixed: [], prNumber: 42, prUrl: "x", fork: "me/b", branch: "y",
134
+ });
135
+ db = updateEntryState(db, { repo: "a/b", prNumber: 42 }, "merged", { cleanedAt: "2026-05-24T00:00:00Z" });
136
+ expect(db.history[0]!.state).toBe("merged");
137
+ expect(db.history[0]!.cleanedAt).toBe("2026-05-24T00:00:00Z");
138
+ });
139
+
140
+ test("filterByState returns only matching entries", () => {
141
+ let db: any = { version: 1, history: [] };
142
+ db = recordOpened(db, { repo: "a/1", rulesFixed: [], prNumber: 1, prUrl: "x", fork: "me/1", branch: "y" });
143
+ db = recordOptOut(db, "a/2");
144
+ db = recordSkipped(db, "a/3", "no fixes");
145
+ expect(filterByState(db, ["open"]).map((e: any) => e.repo)).toEqual(["a/1"]);
146
+ expect(filterByState(db, ["open", "skipped"]).map((e: any) => e.repo).sort()).toEqual(["a/1", "a/3"]);
147
+ });
148
+ });