gsd-pi 2.38.0-dev.add4f78 → 2.38.0-dev.d533afb

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 (117) hide show
  1. package/dist/resource-loader.js +34 -1
  2. package/dist/resources/extensions/github-sync/cli.js +284 -0
  3. package/dist/resources/extensions/github-sync/index.js +73 -0
  4. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  5. package/dist/resources/extensions/github-sync/sync.js +424 -0
  6. package/dist/resources/extensions/github-sync/templates.js +118 -0
  7. package/dist/resources/extensions/github-sync/types.js +7 -0
  8. package/dist/resources/extensions/gsd/auto/session.js +3 -23
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  10. package/dist/resources/extensions/gsd/auto-loop.js +292 -263
  11. package/dist/resources/extensions/gsd/auto-post-unit.js +28 -3
  12. package/dist/resources/extensions/gsd/auto-prompts.js +23 -43
  13. package/dist/resources/extensions/gsd/auto-start.js +7 -1
  14. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  15. package/dist/resources/extensions/gsd/auto.js +143 -80
  16. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  17. package/dist/resources/extensions/gsd/commands.js +2 -1
  18. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  19. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  20. package/dist/resources/extensions/gsd/doctor-providers.js +27 -11
  21. package/dist/resources/extensions/gsd/doctor.js +20 -1
  22. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  23. package/dist/resources/extensions/gsd/files.js +4 -0
  24. package/dist/resources/extensions/gsd/git-service.js +15 -12
  25. package/dist/resources/extensions/gsd/guided-flow.js +82 -32
  26. package/dist/resources/extensions/gsd/index.js +22 -19
  27. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  28. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  29. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  30. package/dist/resources/extensions/gsd/preferences-validation.js +58 -10
  31. package/dist/resources/extensions/gsd/preferences.js +4 -2
  32. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  33. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -2
  34. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  35. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  36. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  37. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  38. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  39. package/dist/resources/extensions/gsd/prompts/run-uat.md +27 -10
  40. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  41. package/dist/resources/extensions/gsd/repo-identity.js +19 -3
  42. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  43. package/dist/resources/extensions/mcp-client/index.js +14 -1
  44. package/package.json +1 -1
  45. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  46. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  47. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  50. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  51. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  52. package/src/resources/extensions/github-sync/cli.ts +364 -0
  53. package/src/resources/extensions/github-sync/index.ts +93 -0
  54. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  55. package/src/resources/extensions/github-sync/sync.ts +556 -0
  56. package/src/resources/extensions/github-sync/templates.ts +183 -0
  57. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  58. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  59. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  60. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  61. package/src/resources/extensions/github-sync/types.ts +47 -0
  62. package/src/resources/extensions/gsd/auto/session.ts +3 -25
  63. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  64. package/src/resources/extensions/gsd/auto-loop.ts +382 -360
  65. package/src/resources/extensions/gsd/auto-post-unit.ts +29 -3
  66. package/src/resources/extensions/gsd/auto-prompts.ts +25 -45
  67. package/src/resources/extensions/gsd/auto-start.ts +11 -1
  68. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  69. package/src/resources/extensions/gsd/auto.ts +139 -86
  70. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  71. package/src/resources/extensions/gsd/commands.ts +2 -2
  72. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  73. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  74. package/src/resources/extensions/gsd/doctor-providers.ts +26 -9
  75. package/src/resources/extensions/gsd/doctor.ts +22 -1
  76. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  77. package/src/resources/extensions/gsd/files.ts +3 -1
  78. package/src/resources/extensions/gsd/git-service.ts +20 -10
  79. package/src/resources/extensions/gsd/guided-flow.ts +110 -38
  80. package/src/resources/extensions/gsd/index.ts +21 -16
  81. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  82. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  83. package/src/resources/extensions/gsd/preferences-types.ts +4 -4
  84. package/src/resources/extensions/gsd/preferences-validation.ts +50 -10
  85. package/src/resources/extensions/gsd/preferences.ts +3 -2
  86. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  87. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  88. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  89. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  90. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  91. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  92. package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
  93. package/src/resources/extensions/gsd/prompts/run-uat.md +27 -10
  94. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  95. package/src/resources/extensions/gsd/repo-identity.ts +20 -3
  96. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  97. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  98. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +122 -68
  99. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
  100. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  101. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  102. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  103. package/src/resources/extensions/gsd/tests/run-uat.test.ts +11 -3
  104. package/src/resources/extensions/gsd/types.ts +0 -1
  105. package/src/resources/extensions/mcp-client/index.ts +17 -1
  106. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  107. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  108. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  109. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  110. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  111. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  112. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  113. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  114. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  115. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  116. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  117. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -23,6 +23,12 @@ import * as _bundledYaml from "yaml";
23
23
  import * as _bundledMcpClient from "@modelcontextprotocol/sdk/client";
24
24
  import * as _bundledMcpStdio from "@modelcontextprotocol/sdk/client/stdio.js";
25
25
  import * as _bundledMcpStreamableHttp from "@modelcontextprotocol/sdk/client/streamableHttp.js";
26
+ import * as _bundledMcpSse from "@modelcontextprotocol/sdk/client/sse.js";
27
+ import * as _bundledMcpServer from "@modelcontextprotocol/sdk/server";
28
+ import * as _bundledMcpServerStdio from "@modelcontextprotocol/sdk/server/stdio.js";
29
+ import * as _bundledMcpServerSse from "@modelcontextprotocol/sdk/server/sse.js";
30
+ import * as _bundledMcpServerStreamableHttp from "@modelcontextprotocol/sdk/server/streamableHttp.js";
31
+ import * as _bundledMcpTypes from "@modelcontextprotocol/sdk/types.js";
26
32
  import { getAgentDir, isBunBinary } from "../../config.js";
27
33
  // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
28
34
  // avoiding a circular dependency. Extensions can import from @gsd/pi-coding-agent.
@@ -44,8 +50,11 @@ import type {
44
50
  ToolDefinition,
45
51
  } from "./types.js";
46
52
 
47
- /** Modules available to extensions via virtualModules (for compiled Bun binary) */
48
- const VIRTUAL_MODULES: Record<string, unknown> = {
53
+ /**
54
+ * Statically imported modules for Bun binary virtualModules.
55
+ * Maps specifier -> module object for subpaths that must be available in compiled binaries.
56
+ */
57
+ const STATIC_BUNDLED_MODULES: Record<string, unknown> = {
49
58
  "@sinclair/typebox": _bundledTypebox,
50
59
  "@gsd/pi-agent-core": _bundledPiAgentCore,
51
60
  "@gsd/pi-tui": _bundledPiTui,
@@ -58,6 +67,17 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
58
67
  "@modelcontextprotocol/sdk/client/stdio.js": _bundledMcpStdio,
59
68
  "@modelcontextprotocol/sdk/client/streamableHttp": _bundledMcpStreamableHttp,
60
69
  "@modelcontextprotocol/sdk/client/streamableHttp.js": _bundledMcpStreamableHttp,
70
+ "@modelcontextprotocol/sdk/client/sse": _bundledMcpSse,
71
+ "@modelcontextprotocol/sdk/client/sse.js": _bundledMcpSse,
72
+ "@modelcontextprotocol/sdk/server": _bundledMcpServer,
73
+ "@modelcontextprotocol/sdk/server/stdio": _bundledMcpServerStdio,
74
+ "@modelcontextprotocol/sdk/server/stdio.js": _bundledMcpServerStdio,
75
+ "@modelcontextprotocol/sdk/server/sse": _bundledMcpServerSse,
76
+ "@modelcontextprotocol/sdk/server/sse.js": _bundledMcpServerSse,
77
+ "@modelcontextprotocol/sdk/server/streamableHttp": _bundledMcpServerStreamableHttp,
78
+ "@modelcontextprotocol/sdk/server/streamableHttp.js": _bundledMcpServerStreamableHttp,
79
+ "@modelcontextprotocol/sdk/types": _bundledMcpTypes,
80
+ "@modelcontextprotocol/sdk/types.js": _bundledMcpTypes,
61
81
  // Aliases for external PI ecosystem packages that import from the original scope
62
82
  "@mariozechner/pi-agent-core": _bundledPiAgentCore,
63
83
  "@mariozechner/pi-tui": _bundledPiTui,
@@ -66,9 +86,198 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
66
86
  "@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
67
87
  };
68
88
 
89
+ /** Modules available to extensions via virtualModules (for compiled Bun binary) */
90
+ const VIRTUAL_MODULES: Record<string, unknown> = { ...STATIC_BUNDLED_MODULES };
91
+
69
92
  const require = createRequire(import.meta.url);
70
93
  const EXTENSION_TIMING_ENABLED = process.env.GSD_STARTUP_TIMING === "1" || process.env.PI_TIMING === "1";
71
94
 
95
+ /**
96
+ * Bundled npm packages whose subpath exports should be auto-resolved for extensions.
97
+ * Each package listed here will have its `exports` field read from package.json,
98
+ * and all subpath exports will be registered as jiti aliases (Node.js mode) so that
99
+ * extensions can import any standard subpath without hitting jiti's CJS double-resolve bug.
100
+ */
101
+ const BUNDLED_PACKAGES_WITH_EXPORTS = [
102
+ "@modelcontextprotocol/sdk",
103
+ "yaml",
104
+ ];
105
+
106
+ /**
107
+ * Read a package's `exports` field and return alias entries mapping
108
+ * specifiers (e.g. `@modelcontextprotocol/sdk/server`) to resolved file paths.
109
+ *
110
+ * Handles:
111
+ * - Explicit subpath exports: `./client` -> `@pkg/client`
112
+ * - Wildcard exports (`./*`): scans the package's dist directory for actual files
113
+ * - Both `.js`-suffixed and bare specifiers for each subpath
114
+ */
115
+ function resolveSubpathExports(packageName: string): Record<string, string> {
116
+ const aliases: Record<string, string> = {};
117
+
118
+ let packageJsonPath: string;
119
+ try {
120
+ // Resolve the package's root directory via its package.json
121
+ packageJsonPath = require.resolve(`${packageName}/package.json`);
122
+ } catch {
123
+ // Package doesn't allow importing package.json via exports — find it manually
124
+ try {
125
+ const anyEntry = require.resolve(packageName);
126
+ // Walk up from the resolved entry to find package.json
127
+ let dir = path.dirname(anyEntry);
128
+ while (dir !== path.dirname(dir)) {
129
+ const candidate = path.join(dir, "package.json");
130
+ if (fs.existsSync(candidate)) {
131
+ try {
132
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
133
+ if (pkg.name === packageName) {
134
+ packageJsonPath = candidate;
135
+ break;
136
+ }
137
+ } catch {
138
+ // not valid JSON, keep walking
139
+ }
140
+ }
141
+ dir = path.dirname(dir);
142
+ }
143
+ } catch {
144
+ return aliases;
145
+ }
146
+ if (!packageJsonPath!) return aliases;
147
+ }
148
+
149
+ let pkg: { exports?: Record<string, unknown> };
150
+ try {
151
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
152
+ } catch {
153
+ return aliases;
154
+ }
155
+
156
+ const exports = pkg.exports;
157
+ if (!exports || typeof exports !== "object") return aliases;
158
+
159
+ const packageDir = path.dirname(packageJsonPath);
160
+
161
+ for (const [subpath, target] of Object.entries(exports)) {
162
+ if (subpath === ".") continue; // Root export handled by static imports
163
+
164
+ // Handle wildcard exports like "./*"
165
+ if (subpath.includes("*")) {
166
+ resolveWildcardExports(packageName, packageDir, subpath, target, aliases);
167
+ continue;
168
+ }
169
+
170
+ // Explicit subpath: "./client" -> "@pkg/client"
171
+ const specifier = `${packageName}/${subpath.replace(/^\.\//, "")}`;
172
+
173
+ try {
174
+ const resolved = require.resolve(specifier);
175
+ aliases[specifier] = resolved;
176
+
177
+ // Add .js-suffixed variant if the specifier doesn't already end in .js
178
+ if (!specifier.endsWith(".js")) {
179
+ const jsSpecifier = `${specifier}.js`;
180
+ try {
181
+ const jsResolved = require.resolve(jsSpecifier);
182
+ aliases[jsSpecifier] = jsResolved;
183
+ } catch {
184
+ // .js variant doesn't resolve — that's fine
185
+ }
186
+ }
187
+
188
+ // Add bare variant (without .js) if it ends in .js
189
+ if (specifier.endsWith(".js")) {
190
+ const bareSpecifier = specifier.slice(0, -3);
191
+ try {
192
+ const bareResolved = require.resolve(bareSpecifier);
193
+ aliases[bareSpecifier] = bareResolved;
194
+ } catch {
195
+ // bare variant doesn't resolve — that's fine
196
+ }
197
+ }
198
+ } catch {
199
+ // Subpath doesn't resolve — skip it
200
+ }
201
+ }
202
+
203
+ return aliases;
204
+ }
205
+
206
+ /**
207
+ * Resolve wildcard export patterns (e.g. `./*`) by scanning the package's
208
+ * file structure to find all matching files and generate alias entries.
209
+ */
210
+ function resolveWildcardExports(
211
+ packageName: string,
212
+ packageDir: string,
213
+ subpathPattern: string,
214
+ target: unknown,
215
+ aliases: Record<string, string>,
216
+ ): void {
217
+ // Extract the target directory pattern from the export target
218
+ // e.g. { "require": "./dist/cjs/*" } -> "dist/cjs"
219
+ let targetDir: string | null = null;
220
+
221
+ if (typeof target === "string") {
222
+ targetDir = target.replace(/\/\*$/, "").replace(/^\.\//, "");
223
+ } else if (target && typeof target === "object") {
224
+ const targetObj = target as Record<string, unknown>;
225
+ // Prefer "require" for CJS compatibility with jiti, fall back to "import"
226
+ const resolved = targetObj.require ?? targetObj.import ?? targetObj.default;
227
+ if (typeof resolved === "string") {
228
+ targetDir = resolved.replace(/\/\*$/, "").replace(/^\.\//, "");
229
+ }
230
+ }
231
+
232
+ if (!targetDir) return;
233
+
234
+ const fullTargetDir = path.join(packageDir, targetDir);
235
+ if (!fs.existsSync(fullTargetDir)) return;
236
+
237
+ // Scan for .js files and generate specifiers
238
+ const subpathPrefix = subpathPattern.replace(/\/?\*$/, "").replace(/^\.\//, "");
239
+ scanDirForExports(packageName, fullTargetDir, subpathPrefix, aliases);
240
+ }
241
+
242
+ /**
243
+ * Recursively scan a directory for .js files and register them as aliases.
244
+ */
245
+ function scanDirForExports(
246
+ packageName: string,
247
+ dir: string,
248
+ relativePath: string,
249
+ aliases: Record<string, string>,
250
+ ): void {
251
+ let entries: fs.Dirent[];
252
+ try {
253
+ entries = fs.readdirSync(dir, { withFileTypes: true });
254
+ } catch {
255
+ return;
256
+ }
257
+
258
+ for (const entry of entries) {
259
+ const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
260
+
261
+ if (entry.isDirectory()) {
262
+ // Skip examples/test directories — extensions don't need them
263
+ if (entry.name === "examples" || entry.name === "__tests__" || entry.name === "test") continue;
264
+ scanDirForExports(packageName, path.join(dir, entry.name), entryRelative, aliases);
265
+ } else if (entry.name.endsWith(".js") && !entry.name.endsWith(".d.js")) {
266
+ const filePath = path.join(dir, entry.name);
267
+ const specifier = `${packageName}/${entryRelative}`;
268
+ // Only add if not already covered by an explicit export
269
+ if (!(specifier in aliases)) {
270
+ aliases[specifier] = filePath;
271
+ }
272
+ // Also add bare (no .js) variant
273
+ const bareSpecifier = specifier.replace(/\.js$/, "");
274
+ if (!(bareSpecifier in aliases)) {
275
+ aliases[bareSpecifier] = filePath;
276
+ }
277
+ }
278
+ }
279
+ }
280
+
72
281
  function logExtensionTiming(extensionPath: string, ms: number, outcome: "loaded" | "failed"): void {
73
282
  if (!EXTENSION_TIMING_ENABLED) return;
74
283
  console.error(`[startup] extension ${outcome}: ${extensionPath} (${ms}ms)`);
@@ -100,7 +309,19 @@ function getAliases(): Record<string, string> {
100
309
  return fileURLToPath(import.meta.resolve(specifier));
101
310
  };
102
311
 
312
+ // Auto-discover subpath exports from bundled npm packages.
313
+ // This ensures extensions can import any standard subpath (e.g. @modelcontextprotocol/sdk/server)
314
+ // without hitting jiti's CJS double-resolve bug.
315
+ const autoDiscovered: Record<string, string> = {};
316
+ for (const packageName of BUNDLED_PACKAGES_WITH_EXPORTS) {
317
+ const subpathAliases = resolveSubpathExports(packageName);
318
+ Object.assign(autoDiscovered, subpathAliases);
319
+ }
320
+
103
321
  _aliases = {
322
+ // Auto-discovered subpath exports (lowest priority — overridden by manual entries below)
323
+ ...autoDiscovered,
324
+ // Manual entries for workspace packages and packages needing special resolution
104
325
  "@gsd/pi-coding-agent": packageIndex,
105
326
  "@gsd/pi-agent-core": resolveWorkspaceOrImport("agent/dist/index.js", "@gsd/pi-agent-core"),
106
327
  "@gsd/pi-tui": resolveWorkspaceOrImport("tui/dist/index.js", "@gsd/pi-tui"),
@@ -108,11 +329,6 @@ function getAliases(): Record<string, string> {
108
329
  "@gsd/pi-ai/oauth": resolveWorkspaceOrImport("ai/dist/oauth.js", "@gsd/pi-ai/oauth"),
109
330
  "@sinclair/typebox": typeboxRoot,
110
331
  "yaml": yamlRoot,
111
- "@modelcontextprotocol/sdk/client": require.resolve("@modelcontextprotocol/sdk/client"),
112
- "@modelcontextprotocol/sdk/client/stdio": require.resolve("@modelcontextprotocol/sdk/client/stdio.js"),
113
- "@modelcontextprotocol/sdk/client/stdio.js": require.resolve("@modelcontextprotocol/sdk/client/stdio.js"),
114
- "@modelcontextprotocol/sdk/client/streamableHttp": require.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"),
115
- "@modelcontextprotocol/sdk/client/streamableHttp.js": require.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"),
116
332
  // Aliases for external PI ecosystem packages that import from the original scope
117
333
  "@mariozechner/pi-coding-agent": packageIndex,
118
334
  "@mariozechner/pi-agent-core": resolveWorkspaceOrImport("agent/dist/index.js", "@gsd/pi-agent-core"),
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Thin wrapper around the `gh` CLI.
3
+ *
4
+ * Every public function returns `GhResult<T>` — never throws.
5
+ * Uses `execFileSync` (not `execSync`) for safety.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+
10
+ // ─── Result Type ────────────────────────────────────────────────────────────
11
+
12
+ export interface GhResult<T> {
13
+ ok: boolean;
14
+ data?: T;
15
+ error?: string;
16
+ }
17
+
18
+ function ok<T>(data: T): GhResult<T> {
19
+ return { ok: true, data };
20
+ }
21
+
22
+ function fail<T>(error: string): GhResult<T> {
23
+ return { ok: false, error };
24
+ }
25
+
26
+ // ─── gh Availability ────────────────────────────────────────────────────────
27
+
28
+ let _ghAvailable: boolean | null = null;
29
+
30
+ export function ghIsAvailable(): boolean {
31
+ if (_ghAvailable !== null) return _ghAvailable;
32
+ try {
33
+ execFileSync("gh", ["--version"], {
34
+ encoding: "utf-8",
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ timeout: 5_000,
37
+ });
38
+ _ghAvailable = true;
39
+ } catch {
40
+ _ghAvailable = false;
41
+ }
42
+ return _ghAvailable;
43
+ }
44
+
45
+ /** Reset cached availability (for testing). */
46
+ export function _resetGhCache(): void {
47
+ _ghAvailable = null;
48
+ }
49
+
50
+ // ─── Rate Limit Check ───────────────────────────────────────────────────────
51
+
52
+ let _rateLimitCheckedAt = 0;
53
+ let _rateLimitOk = true;
54
+ const RATE_LIMIT_CHECK_INTERVAL_MS = 300_000; // 5 minutes
55
+
56
+ export function ghHasRateLimit(cwd: string): boolean {
57
+ const now = Date.now();
58
+ if (now - _rateLimitCheckedAt < RATE_LIMIT_CHECK_INTERVAL_MS) return _rateLimitOk;
59
+ _rateLimitCheckedAt = now;
60
+ try {
61
+ const raw = execFileSync("gh", ["api", "rate_limit", "--jq", ".rate.remaining"], {
62
+ cwd,
63
+ encoding: "utf-8",
64
+ stdio: ["ignore", "pipe", "ignore"],
65
+ timeout: 10_000,
66
+ }).trim();
67
+ const remaining = parseInt(raw, 10);
68
+ _rateLimitOk = Number.isFinite(remaining) && remaining >= 100;
69
+ } catch {
70
+ // Can't check — assume OK so we don't silently disable sync
71
+ _rateLimitOk = true;
72
+ }
73
+ return _rateLimitOk;
74
+ }
75
+
76
+ // ─── Helpers ────────────────────────────────────────────────────────────────
77
+
78
+ const GH_TIMEOUT = 15_000;
79
+ const MAX_BODY_LENGTH = 65_000;
80
+
81
+ function truncateBody(body: string): string {
82
+ if (body.length <= MAX_BODY_LENGTH) return body;
83
+ return body.slice(0, MAX_BODY_LENGTH) + "\n\n---\n*Body truncated (exceeded 65K characters)*";
84
+ }
85
+
86
+ function runGh(args: string[], cwd: string): GhResult<string> {
87
+ try {
88
+ const stdout = execFileSync("gh", args, {
89
+ cwd,
90
+ encoding: "utf-8",
91
+ stdio: ["ignore", "pipe", "pipe"],
92
+ timeout: GH_TIMEOUT,
93
+ }).trim();
94
+ return ok(stdout);
95
+ } catch (err) {
96
+ const msg = err instanceof Error ? err.message : String(err);
97
+ return fail(msg);
98
+ }
99
+ }
100
+
101
+ function runGhJson<T>(args: string[], cwd: string): GhResult<T> {
102
+ const result = runGh(args, cwd);
103
+ if (!result.ok) return fail(result.error!);
104
+ try {
105
+ return ok(JSON.parse(result.data!) as T);
106
+ } catch {
107
+ return fail(`Failed to parse JSON: ${result.data}`);
108
+ }
109
+ }
110
+
111
+ // ─── Repo Detection ─────────────────────────────────────────────────────────
112
+
113
+ export function ghDetectRepo(cwd: string): GhResult<string> {
114
+ const result = runGh(
115
+ ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
116
+ cwd,
117
+ );
118
+ if (!result.ok) return fail(result.error!);
119
+ const repo = result.data!.trim();
120
+ if (!repo || !repo.includes("/")) return fail("Could not detect repo");
121
+ return ok(repo);
122
+ }
123
+
124
+ // ─── Issues ─────────────────────────────────────────────────────────────────
125
+
126
+ export interface CreateIssueOpts {
127
+ repo: string;
128
+ title: string;
129
+ body: string;
130
+ labels?: string[];
131
+ milestone?: number;
132
+ parentIssue?: number;
133
+ }
134
+
135
+ export function ghCreateIssue(cwd: string, opts: CreateIssueOpts): GhResult<number> {
136
+ const args = [
137
+ "issue", "create",
138
+ "--repo", opts.repo,
139
+ "--title", opts.title,
140
+ "--body", truncateBody(opts.body),
141
+ ];
142
+ if (opts.labels?.length) {
143
+ args.push("--label", opts.labels.join(","));
144
+ }
145
+ if (opts.milestone) {
146
+ args.push("--milestone", String(opts.milestone));
147
+ }
148
+
149
+ const result = runGh(args, cwd);
150
+ if (!result.ok) return fail(result.error!);
151
+
152
+ // gh issue create returns the URL; extract issue number
153
+ const match = result.data!.match(/\/issues\/(\d+)/);
154
+ if (!match) return fail(`Could not parse issue number from: ${result.data}`);
155
+ const issueNumber = parseInt(match[1], 10);
156
+
157
+ // If parent specified, add as sub-issue via GraphQL
158
+ if (opts.parentIssue) {
159
+ ghAddSubIssue(cwd, opts.repo, opts.parentIssue, issueNumber);
160
+ }
161
+
162
+ return ok(issueNumber);
163
+ }
164
+
165
+ export function ghCloseIssue(cwd: string, repo: string, issueNumber: number, comment?: string): GhResult<void> {
166
+ if (comment) {
167
+ ghAddComment(cwd, repo, issueNumber, comment);
168
+ }
169
+ const result = runGh(
170
+ ["issue", "close", String(issueNumber), "--repo", repo],
171
+ cwd,
172
+ );
173
+ if (!result.ok) return fail(result.error!);
174
+ return ok(undefined);
175
+ }
176
+
177
+ export function ghAddComment(cwd: string, repo: string, issueNumber: number, body: string): GhResult<void> {
178
+ const result = runGh(
179
+ ["issue", "comment", String(issueNumber), "--repo", repo, "--body", truncateBody(body)],
180
+ cwd,
181
+ );
182
+ if (!result.ok) return fail(result.error!);
183
+ return ok(undefined);
184
+ }
185
+
186
+ // ─── Sub-Issues (GraphQL) ───────────────────────────────────────────────────
187
+
188
+ function ghAddSubIssue(cwd: string, repo: string, parentNumber: number, childNumber: number): GhResult<void> {
189
+ // Get node IDs for both issues
190
+ const parentResult = runGhJson<{ id: string }>(
191
+ ["api", `repos/${repo}/issues/${parentNumber}`, "--jq", "{id: .node_id}"],
192
+ cwd,
193
+ );
194
+ const childResult = runGhJson<{ id: string }>(
195
+ ["api", `repos/${repo}/issues/${childNumber}`, "--jq", "{id: .node_id}"],
196
+ cwd,
197
+ );
198
+
199
+ if (!parentResult.ok || !childResult.ok) {
200
+ return fail("Could not resolve issue node IDs for sub-issue linking");
201
+ }
202
+
203
+ const mutation = `mutation { addSubIssue(input: { issueId: "${parentResult.data!.id}", subIssueId: "${childResult.data!.id}" }) { issue { id } } }`;
204
+ return runGh(["api", "graphql", "-f", `query=${mutation}`], cwd) as GhResult<void>;
205
+ }
206
+
207
+ // ─── Milestones ─────────────────────────────────────────────────────────────
208
+
209
+ export function ghCreateMilestone(cwd: string, repo: string, title: string, description: string): GhResult<number> {
210
+ const result = runGhJson<{ number: number }>(
211
+ [
212
+ "api", `repos/${repo}/milestones`,
213
+ "-X", "POST",
214
+ "-f", `title=${title}`,
215
+ "-f", `description=${truncateBody(description)}`,
216
+ "-f", "state=open",
217
+ "--jq", "{number: .number}",
218
+ ],
219
+ cwd,
220
+ );
221
+ if (!result.ok) return fail(result.error!);
222
+ return ok(result.data!.number);
223
+ }
224
+
225
+ export function ghCloseMilestone(cwd: string, repo: string, milestoneNumber: number): GhResult<void> {
226
+ const result = runGh(
227
+ [
228
+ "api", `repos/${repo}/milestones/${milestoneNumber}`,
229
+ "-X", "PATCH",
230
+ "-f", "state=closed",
231
+ ],
232
+ cwd,
233
+ );
234
+ if (!result.ok) return fail(result.error!);
235
+ return ok(undefined);
236
+ }
237
+
238
+ // ─── Pull Requests ──────────────────────────────────────────────────────────
239
+
240
+ export interface CreatePROpts {
241
+ repo: string;
242
+ base: string;
243
+ head: string;
244
+ title: string;
245
+ body: string;
246
+ draft?: boolean;
247
+ }
248
+
249
+ export function ghCreatePR(cwd: string, opts: CreatePROpts): GhResult<number> {
250
+ const args = [
251
+ "pr", "create",
252
+ "--repo", opts.repo,
253
+ "--base", opts.base,
254
+ "--head", opts.head,
255
+ "--title", opts.title,
256
+ "--body", truncateBody(opts.body),
257
+ ];
258
+ if (opts.draft) args.push("--draft");
259
+
260
+ const result = runGh(args, cwd);
261
+ if (!result.ok) return fail(result.error!);
262
+
263
+ const match = result.data!.match(/\/pull\/(\d+)/);
264
+ if (!match) return fail(`Could not parse PR number from: ${result.data}`);
265
+ return ok(parseInt(match[1], 10));
266
+ }
267
+
268
+ export function ghMarkPRReady(cwd: string, repo: string, prNumber: number): GhResult<void> {
269
+ const result = runGh(
270
+ ["pr", "ready", String(prNumber), "--repo", repo],
271
+ cwd,
272
+ );
273
+ if (!result.ok) return fail(result.error!);
274
+ return ok(undefined);
275
+ }
276
+
277
+ export function ghMergePR(cwd: string, repo: string, prNumber: number, strategy: "squash" | "merge" = "squash"): GhResult<void> {
278
+ const args = [
279
+ "pr", "merge", String(prNumber),
280
+ "--repo", repo,
281
+ strategy === "squash" ? "--squash" : "--merge",
282
+ "--delete-branch",
283
+ ];
284
+ const result = runGh(args, cwd);
285
+ if (!result.ok) return fail(result.error!);
286
+ return ok(undefined);
287
+ }
288
+
289
+ // ─── Projects v2 ────────────────────────────────────────────────────────────
290
+
291
+ export function ghAddToProject(cwd: string, repo: string, projectNumber: number, issueNumber: number): GhResult<void> {
292
+ // Get the issue's node ID first
293
+ const issueResult = runGhJson<{ id: string }>(
294
+ ["api", `repos/${repo}/issues/${issueNumber}`, "--jq", "{id: .node_id}"],
295
+ cwd,
296
+ );
297
+ if (!issueResult.ok) return fail(issueResult.error!);
298
+
299
+ // Get the project's node ID
300
+ const [owner] = repo.split("/");
301
+ const projectResult = runGhJson<{ id: string }>(
302
+ [
303
+ "api", "graphql",
304
+ "-f", `query=query { user(login: "${owner}") { projectV2(number: ${projectNumber}) { id } } }`,
305
+ "--jq", ".data.user.projectV2.id",
306
+ ],
307
+ cwd,
308
+ );
309
+
310
+ // Try org if user fails
311
+ let projectId: string | undefined;
312
+ if (projectResult.ok && projectResult.data?.id) {
313
+ projectId = projectResult.data.id;
314
+ } else {
315
+ const orgResult = runGhJson<{ id: string }>(
316
+ [
317
+ "api", "graphql",
318
+ "-f", `query=query { organization(login: "${owner}") { projectV2(number: ${projectNumber}) { id } } }`,
319
+ "--jq", ".data.organization.projectV2.id",
320
+ ],
321
+ cwd,
322
+ );
323
+ if (orgResult.ok) projectId = orgResult.data?.id;
324
+ }
325
+
326
+ if (!projectId) return fail("Could not find project");
327
+
328
+ const mutation = `mutation { addProjectV2ItemById(input: { projectId: "${projectId}", contentId: "${issueResult.data!.id}" }) { item { id } } }`;
329
+ return runGh(["api", "graphql", "-f", `query=${mutation}`], cwd) as GhResult<void>;
330
+ }
331
+
332
+ // ─── Branch Operations ──────────────────────────────────────────────────────
333
+
334
+ export function ghPushBranch(cwd: string, branch: string, setUpstream = true): GhResult<void> {
335
+ const args = ["git", "push"];
336
+ if (setUpstream) args.push("-u", "origin", branch);
337
+ else args.push("origin", branch);
338
+
339
+ try {
340
+ execFileSync(args[0], args.slice(1), {
341
+ cwd,
342
+ encoding: "utf-8",
343
+ stdio: ["ignore", "pipe", "pipe"],
344
+ timeout: 30_000,
345
+ });
346
+ return ok(undefined);
347
+ } catch (err) {
348
+ return fail(err instanceof Error ? err.message : String(err));
349
+ }
350
+ }
351
+
352
+ export function ghCreateBranch(cwd: string, branch: string, from: string): GhResult<void> {
353
+ try {
354
+ execFileSync("git", ["branch", branch, from], {
355
+ cwd,
356
+ encoding: "utf-8",
357
+ stdio: ["ignore", "pipe", "pipe"],
358
+ timeout: 10_000,
359
+ });
360
+ return ok(undefined);
361
+ } catch (err) {
362
+ return fail(err instanceof Error ? err.message : String(err));
363
+ }
364
+ }