whitesmith 0.0.1 → 0.0.2

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 (64) hide show
  1. package/dist/auto-work.d.ts +11 -0
  2. package/dist/auto-work.d.ts.map +1 -0
  3. package/dist/auto-work.js +22 -0
  4. package/dist/auto-work.js.map +1 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +99 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/comment.d.ts +29 -0
  9. package/dist/comment.d.ts.map +1 -0
  10. package/dist/comment.js +390 -0
  11. package/dist/comment.js.map +1 -0
  12. package/dist/git.d.ts +12 -0
  13. package/dist/git.d.ts.map +1 -1
  14. package/dist/git.js +57 -14
  15. package/dist/git.js.map +1 -1
  16. package/dist/harnesses/pi.d.ts +2 -0
  17. package/dist/harnesses/pi.d.ts.map +1 -1
  18. package/dist/harnesses/pi.js +92 -6
  19. package/dist/harnesses/pi.js.map +1 -1
  20. package/dist/install-ci.d.ts +7 -0
  21. package/dist/install-ci.d.ts.map +1 -0
  22. package/dist/install-ci.js +760 -0
  23. package/dist/install-ci.js.map +1 -0
  24. package/dist/orchestrator.d.ts +24 -4
  25. package/dist/orchestrator.d.ts.map +1 -1
  26. package/dist/orchestrator.js +252 -67
  27. package/dist/orchestrator.js.map +1 -1
  28. package/dist/prompts.d.ts.map +1 -1
  29. package/dist/prompts.js +1 -0
  30. package/dist/prompts.js.map +1 -1
  31. package/dist/providers/github-ci.d.ts +16 -0
  32. package/dist/providers/github-ci.d.ts.map +1 -0
  33. package/dist/providers/github-ci.js +733 -0
  34. package/dist/providers/github-ci.js.map +1 -0
  35. package/dist/providers/github.d.ts +21 -0
  36. package/dist/providers/github.d.ts.map +1 -1
  37. package/dist/providers/github.js +88 -3
  38. package/dist/providers/github.js.map +1 -1
  39. package/dist/providers/index.d.ts +1 -0
  40. package/dist/providers/index.d.ts.map +1 -1
  41. package/dist/providers/issue-provider.d.ts +26 -0
  42. package/dist/providers/issue-provider.d.ts.map +1 -1
  43. package/dist/task-manager.d.ts +4 -0
  44. package/dist/task-manager.d.ts.map +1 -1
  45. package/dist/task-manager.js +6 -0
  46. package/dist/task-manager.js.map +1 -1
  47. package/dist/types.d.ts +9 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -1
  51. package/package.json +2 -1
  52. package/src/auto-work.ts +26 -0
  53. package/src/cli.ts +114 -0
  54. package/src/comment.ts +531 -0
  55. package/src/git.ts +58 -12
  56. package/src/harnesses/pi.ts +108 -6
  57. package/src/orchestrator.ts +287 -76
  58. package/src/prompts.ts +1 -0
  59. package/src/providers/github-ci.ts +840 -0
  60. package/src/providers/github.ts +118 -5
  61. package/src/providers/index.ts +1 -0
  62. package/src/providers/issue-provider.ts +25 -1
  63. package/src/task-manager.ts +7 -0
  64. package/src/types.ts +7 -0
@@ -0,0 +1,760 @@
1
+ import { select, input, confirm, password } from '@inquirer/prompts';
2
+ import { execSync } from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ // ─── GitHub helpers ──────────────────────────────────────────────────────────
6
+ function detectRepo(workDir) {
7
+ try {
8
+ const url = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
9
+ cwd: workDir,
10
+ stdio: ['pipe', 'pipe', 'pipe'],
11
+ })
12
+ .toString()
13
+ .trim();
14
+ return url || undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function ghIsAvailable() {
21
+ try {
22
+ execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function setGitHubSecret(repo, name, value) {
30
+ execSync(`gh secret set ${name} --repo "${repo}"`, {
31
+ input: value,
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ });
34
+ }
35
+ // ─── Interactive Setup ───────────────────────────────────────────────────────
36
+ async function promptProviders() {
37
+ const providers = [];
38
+ let addMore = true;
39
+ while (addMore) {
40
+ const providerType = await select({
41
+ message: providers.length === 0 ? 'Add a provider:' : 'Add another provider:',
42
+ choices: [
43
+ { name: 'Anthropic (built-in provider, needs API key)', value: 'anthropic' },
44
+ { name: 'OpenAI (built-in provider, needs API key)', value: 'openai' },
45
+ { name: 'Custom provider', value: 'custom' },
46
+ ],
47
+ });
48
+ if (providerType === 'anthropic' || providerType === 'openai') {
49
+ const provider = await promptBuiltinProvider(providerType);
50
+ providers.push(provider);
51
+ }
52
+ else {
53
+ const provider = await promptCustomProvider();
54
+ providers.push(provider);
55
+ }
56
+ addMore = await confirm({ message: 'Add another provider?', default: false });
57
+ }
58
+ return providers;
59
+ }
60
+ async function promptBuiltinProvider(type) {
61
+ const defaultEnvVar = type === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
62
+ const defaultModel = type === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o';
63
+ const apiKeyEnvVar = await input({
64
+ message: 'GitHub secret name for the API key:',
65
+ default: defaultEnvVar,
66
+ });
67
+ const customUrl = await input({
68
+ message: 'Custom base URL (leave empty for default):',
69
+ });
70
+ // Collect models
71
+ const models = [];
72
+ let addModel = true;
73
+ while (addModel) {
74
+ const modelId = await input({
75
+ message: models.length === 0 ? 'Model ID:' : 'Another model ID:',
76
+ default: models.length === 0 ? defaultModel : undefined,
77
+ });
78
+ models.push({ id: modelId });
79
+ addModel = await confirm({ message: 'Add another model?', default: false });
80
+ }
81
+ return {
82
+ name: type,
83
+ baseUrl: customUrl || undefined,
84
+ apiKeyEnvVar,
85
+ models,
86
+ builtin: true,
87
+ };
88
+ }
89
+ async function promptCustomProvider() {
90
+ const name = await input({ message: 'Provider name:' });
91
+ const baseUrl = await input({ message: 'Base URL:' });
92
+ const api = await select({
93
+ message: 'API type:',
94
+ choices: [
95
+ { name: 'Anthropic Messages API', value: 'anthropic-messages' },
96
+ { name: 'OpenAI Completions API', value: 'openai-completions' },
97
+ ],
98
+ });
99
+ const apiKeyEnvVar = await input({
100
+ message: 'GitHub secret name for the API key:',
101
+ });
102
+ const needsCompat = api === 'openai-completions';
103
+ let compat;
104
+ if (needsCompat) {
105
+ const supportsDeveloperRole = await confirm({
106
+ message: 'Does this provider support the developer role?',
107
+ default: true,
108
+ });
109
+ const supportsReasoningEffort = await confirm({
110
+ message: 'Does this provider support reasoning effort?',
111
+ default: true,
112
+ });
113
+ if (!supportsDeveloperRole || !supportsReasoningEffort) {
114
+ compat = { supportsDeveloperRole, supportsReasoningEffort };
115
+ }
116
+ }
117
+ const models = [];
118
+ let addModel = true;
119
+ while (addModel) {
120
+ const modelId = await input({
121
+ message: models.length === 0 ? 'Model ID:' : 'Another model ID:',
122
+ });
123
+ models.push({ id: modelId });
124
+ addModel = await confirm({ message: 'Add another model?', default: false });
125
+ }
126
+ return {
127
+ name,
128
+ baseUrl,
129
+ api,
130
+ apiKeyEnvVar,
131
+ models,
132
+ builtin: false,
133
+ compat,
134
+ };
135
+ }
136
+ async function promptDefaults(providers) {
137
+ let provider;
138
+ let model;
139
+ if (providers.length === 1) {
140
+ provider = providers[0].name;
141
+ }
142
+ else {
143
+ provider = await select({
144
+ message: 'Default provider:',
145
+ choices: providers.map((p) => ({ name: p.name, value: p.name })),
146
+ });
147
+ }
148
+ const selected = providers.find((p) => p.name === provider);
149
+ if (selected.models.length === 1) {
150
+ model = selected.models[0].id;
151
+ }
152
+ else {
153
+ model = await select({
154
+ message: 'Default model:',
155
+ choices: selected.models.map((m) => ({ name: m.id, value: m.id })),
156
+ });
157
+ }
158
+ return { provider, model };
159
+ }
160
+ /**
161
+ * Prompt for API key values and set them as GitHub secrets via `gh`.
162
+ * Returns the list of secrets that were set.
163
+ */
164
+ async function promptAndSetSecrets(repo, providers) {
165
+ const setSecrets = [];
166
+ const seen = new Set();
167
+ for (const p of providers) {
168
+ if (seen.has(p.apiKeyEnvVar))
169
+ continue;
170
+ seen.add(p.apiKeyEnvVar);
171
+ const apiKey = await password({
172
+ message: `Enter API key for ${p.name} (secret: ${p.apiKeyEnvVar}):`,
173
+ });
174
+ if (!apiKey) {
175
+ console.log(` ⚠ Skipped ${p.apiKeyEnvVar} (empty)`);
176
+ continue;
177
+ }
178
+ try {
179
+ setGitHubSecret(repo, p.apiKeyEnvVar, apiKey);
180
+ console.log(` ✅ Secret ${p.apiKeyEnvVar} set on ${repo}`);
181
+ setSecrets.push(p.apiKeyEnvVar);
182
+ }
183
+ catch (error) {
184
+ const msg = error.stderr?.toString() || error.message || 'unknown error';
185
+ console.error(` ❌ Failed to set ${p.apiKeyEnvVar}: ${msg}`);
186
+ }
187
+ }
188
+ return setSecrets;
189
+ }
190
+ // ─── models.json generation ──────────────────────────────────────────────────
191
+ function buildModelsJson(providers) {
192
+ const providersObj = {};
193
+ for (const p of providers) {
194
+ if (p.builtin) {
195
+ // Built-in providers: apiKey references the env var name (pi resolves it at runtime)
196
+ const entry = { apiKey: p.apiKeyEnvVar };
197
+ if (p.baseUrl)
198
+ entry.baseUrl = p.baseUrl;
199
+ providersObj[p.name] = entry;
200
+ }
201
+ else {
202
+ const entry = {
203
+ baseUrl: p.baseUrl,
204
+ api: p.api,
205
+ apiKey: p.apiKeyEnvVar,
206
+ models: p.models,
207
+ };
208
+ if (p.compat)
209
+ entry.compat = p.compat;
210
+ providersObj[p.name] = entry;
211
+ }
212
+ }
213
+ return { providers: providersObj };
214
+ }
215
+ // ─── Workflow Templates ──────────────────────────────────────────────────────
216
+ function indent(text, spaces) {
217
+ const pad = ' '.repeat(spaces);
218
+ return text
219
+ .split('\n')
220
+ .map((line) => (line.trim() === '' ? '' : pad + line))
221
+ .join('\n');
222
+ }
223
+ function generateModelsJsonStep(config) {
224
+ const modelsJson = buildModelsJson(config.providers);
225
+ const modelsJsonStr = JSON.stringify(modelsJson, null, 2);
226
+ return `\
227
+ - name: Configure pi models
228
+ run: |
229
+ mkdir -p ~/.pi/agent
230
+ cat > ~/.pi/agent/models.json << 'MODELS_EOF'
231
+ ${indent(modelsJsonStr, 10)}
232
+ MODELS_EOF`;
233
+ }
234
+ function generateAuthJsonSteps() {
235
+ return `\
236
+ - name: Configure pi auth
237
+ env:
238
+ PI_AUTH_JSON: \${{ secrets.PI_AUTH_JSON }}
239
+ run: |
240
+ if [ -z "$PI_AUTH_JSON" ]; then
241
+ echo "ERROR: PI_AUTH_JSON secret is not configured."
242
+ echo "Set it to the contents of ~/.pi/agent/auth.json"
243
+ exit 1
244
+ fi
245
+ mkdir -p ~/.pi/agent
246
+ echo "$PI_AUTH_JSON" > ~/.pi/agent/auth.json
247
+ chmod 600 ~/.pi/agent/auth.json
248
+
249
+ # Workaround for https://github.com/badlogic/pi-mono/issues/2743
250
+ - name: Refresh OAuth token
251
+ env:
252
+ GH_PAT: \${{ secrets.GH_PAT }}
253
+ run: node .github/scripts/refresh-oauth-token.mjs`;
254
+ }
255
+ function generateAuthSteps(config) {
256
+ if (config.authMode === 'auth-json') {
257
+ return generateAuthJsonSteps();
258
+ }
259
+ return generateModelsJsonStep(config);
260
+ }
261
+ function generateRunEnvBlock(config) {
262
+ const envs = {
263
+ GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}',
264
+ };
265
+ if (config.authMode === 'models-json') {
266
+ for (const p of config.providers) {
267
+ envs[p.apiKeyEnvVar] = `\${{ secrets.${p.apiKeyEnvVar} }}`;
268
+ }
269
+ }
270
+ return Object.entries(envs)
271
+ .map(([k, v]) => ` ${k}: ${v}`)
272
+ .join('\n');
273
+ }
274
+ function generateMainWorkflow(config) {
275
+ const authSteps = generateAuthSteps(config);
276
+ const envBlock = generateRunEnvBlock(config);
277
+ return `\
278
+ # NOTE: This workflow requires the repo setting:
279
+ # Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"
280
+ # Without this, PR creation will fail with a permissions error.
281
+ name: whitesmith
282
+
283
+ on:
284
+ schedule:
285
+ - cron: '*/15 * * * *'
286
+ workflow_dispatch:
287
+ inputs:
288
+ max_iterations:
289
+ description: 'Maximum iterations'
290
+ default: '3'
291
+ type: string
292
+ provider:
293
+ description: 'AI provider (e.g. ${config.defaultProvider})'
294
+ required: false
295
+ type: string
296
+ model:
297
+ description: 'AI model ID (e.g. ${config.defaultModel})'
298
+ required: false
299
+ type: string
300
+
301
+ concurrency:
302
+ group: whitesmith-loop
303
+ cancel-in-progress: false
304
+
305
+ permissions:
306
+ contents: write
307
+ issues: write
308
+ pull-requests: write
309
+
310
+ jobs:
311
+ run:
312
+ runs-on: ubuntu-latest
313
+ steps:
314
+ - uses: actions/checkout@v4
315
+ with:
316
+ fetch-depth: 0
317
+ token: \${{ secrets.GITHUB_TOKEN }}
318
+
319
+ - name: Setup Node.js
320
+ uses: actions/setup-node@v4
321
+ with:
322
+ node-version: '22'
323
+
324
+ - name: Configure git
325
+ run: |
326
+ git config user.name "whitesmith[bot]"
327
+ git config user.email "whitesmith[bot]@users.noreply.github.com"
328
+
329
+ - name: Get npm global prefix
330
+ id: npm-prefix
331
+ run: echo "dir=$(npm prefix -g)" >> "$GITHUB_OUTPUT"
332
+
333
+ - name: Cache global npm packages
334
+ id: npm-cache
335
+ uses: actions/cache@v4
336
+ with:
337
+ path: \${{ steps.npm-prefix.outputs.dir }}
338
+ key: npm-global-\${{ runner.os }}-pi-v1
339
+
340
+ - name: Install whitesmith and pi
341
+ if: steps.npm-cache.outputs.cache-hit != 'true'
342
+ run: |
343
+ npm install -g whitesmith
344
+ npm install -g @mariozechner/pi-coding-agent
345
+
346
+ ${authSteps}
347
+
348
+ - name: Run whitesmith
349
+ env:
350
+ ${envBlock}
351
+ run: |
352
+ PROVIDER="\${{ inputs.provider || '${config.defaultProvider}' }}"
353
+ MODEL="\${{ inputs.model || '${config.defaultModel}' }}"
354
+ whitesmith run . \\
355
+ --agent-cmd "pi" \\
356
+ --provider "$PROVIDER" \\
357
+ --model "$MODEL" \\
358
+ --max-iterations \${{ inputs.max_iterations || '3' }}
359
+ `;
360
+ }
361
+ function generateCommentWorkflow(config) {
362
+ const authSteps = generateAuthSteps(config);
363
+ const envBlock = generateRunEnvBlock(config);
364
+ return `\
365
+ # NOTE: This workflow requires the repo setting:
366
+ # Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"
367
+ name: whitesmith-comment
368
+
369
+ on:
370
+ issue_comment:
371
+ types: [created]
372
+
373
+ concurrency:
374
+ group: whitesmith-comment-\${{ github.event.issue.number }}
375
+ cancel-in-progress: false
376
+
377
+ permissions:
378
+ contents: write
379
+ issues: write
380
+ pull-requests: write
381
+
382
+ jobs:
383
+ check:
384
+ runs-on: ubuntu-latest
385
+ outputs:
386
+ should_run: \${{ steps.check.outputs.should_run }}
387
+ steps:
388
+ - name: Check if should run
389
+ id: check
390
+ env:
391
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
392
+ COMMENT_BODY: \${{ github.event.comment.body }}
393
+ run: |
394
+ # Always run if comment contains /whitesmith
395
+ if echo "$COMMENT_BODY" | grep -q '/whitesmith'; then
396
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
397
+ echo "Triggered by /whitesmith keyword"
398
+ exit 0
399
+ fi
400
+
401
+ # For PR comments, auto-trigger if the PR is on a whitesmith branch
402
+ if [ -n "\${{ github.event.issue.pull_request.url }}" ]; then
403
+ BRANCH=$(gh pr view \${{ github.event.issue.number }} \\
404
+ --repo \${{ github.repository }} \\
405
+ --json headRefName -q .headRefName)
406
+ echo "PR branch: $BRANCH"
407
+ if echo "$BRANCH" | grep -qE '^(investigate|task)/'; then
408
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
409
+ echo "Triggered by comment on whitesmith PR branch"
410
+ exit 0
411
+ fi
412
+ fi
413
+
414
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
415
+ echo "Skipping: not a /whitesmith command and not a whitesmith PR"
416
+
417
+ run:
418
+ needs: check
419
+ runs-on: ubuntu-latest
420
+ if: needs.check.outputs.should_run == 'true'
421
+ steps:
422
+ - name: React with eyes to acknowledge
423
+ env:
424
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
425
+ run: |
426
+ gh api repos/\${{ github.repository }}/issues/comments/\${{ github.event.comment.id }}/reactions \\
427
+ -f content=eyes
428
+
429
+ - uses: actions/checkout@v4
430
+ with:
431
+ fetch-depth: 0
432
+ token: \${{ secrets.GITHUB_TOKEN }}
433
+
434
+ - name: Setup Node.js
435
+ uses: actions/setup-node@v4
436
+ with:
437
+ node-version: '22'
438
+
439
+ - name: Configure git
440
+ run: |
441
+ git config user.name "whitesmith[bot]"
442
+ git config user.email "whitesmith[bot]@users.noreply.github.com"
443
+
444
+ - name: Get npm global prefix
445
+ id: npm-prefix
446
+ run: echo "dir=$(npm prefix -g)" >> "$GITHUB_OUTPUT"
447
+
448
+ - name: Cache global npm packages
449
+ id: npm-cache
450
+ uses: actions/cache@v4
451
+ with:
452
+ path: \${{ steps.npm-prefix.outputs.dir }}
453
+ key: npm-global-\${{ runner.os }}-pi-v1
454
+
455
+ - name: Install whitesmith and pi
456
+ if: steps.npm-cache.outputs.cache-hit != 'true'
457
+ run: |
458
+ npm install -g whitesmith
459
+ npm install -g @mariozechner/pi-coding-agent
460
+
461
+ ${authSteps}
462
+
463
+ - name: Save comment body to file
464
+ env:
465
+ COMMENT_BODY: \${{ github.event.comment.body }}
466
+ run: |
467
+ printf '%s' "$COMMENT_BODY" > .whitesmith-comment-body.txt
468
+
469
+ - name: Run whitesmith comment
470
+ env:
471
+ ${envBlock}
472
+ run: |
473
+ whitesmith comment . \\
474
+ --number "\${{ github.event.issue.number }}" \\
475
+ --body-file .whitesmith-comment-body.txt \\
476
+ --provider "${config.defaultProvider}" \\
477
+ --model "${config.defaultModel}" \\
478
+ --post
479
+
480
+ - name: React with checkmark on success
481
+ if: success()
482
+ env:
483
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
484
+ run: |
485
+ gh api repos/\${{ github.repository }}/issues/comments/\${{ github.event.comment.id }}/reactions \\
486
+ -f content="+1"
487
+
488
+ - name: React with X and comment on failure
489
+ if: failure()
490
+ env:
491
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
492
+ run: |
493
+ gh api repos/\${{ github.repository }}/issues/comments/\${{ github.event.comment.id }}/reactions \\
494
+ -f content="-1"
495
+ gh issue comment \${{ github.event.issue.number }} \\
496
+ --repo \${{ github.repository }} \\
497
+ --body "❌ Agent run failed for [this comment](\${{ github.event.comment.html_url }}). Check the [workflow run](\${{ github.server_url }}/\${{ github.repository }}/actions/runs/\${{ github.run_id }}) for details."
498
+ `;
499
+ }
500
+ function generateReconcileWorkflow() {
501
+ return `\
502
+ name: whitesmith-reconcile
503
+
504
+ on:
505
+ pull_request:
506
+ types: [closed]
507
+ branches: [main]
508
+
509
+ permissions:
510
+ contents: read
511
+ issues: write
512
+ pull-requests: read
513
+
514
+ jobs:
515
+ reconcile:
516
+ if: github.event.pull_request.merged == true
517
+ runs-on: ubuntu-latest
518
+ steps:
519
+ - uses: actions/checkout@v4
520
+
521
+ - name: Setup Node.js
522
+ uses: actions/setup-node@v4
523
+ with:
524
+ node-version: '22'
525
+
526
+ - name: Get npm global prefix
527
+ id: npm-prefix
528
+ run: echo "dir=$(npm prefix -g)" >> "$GITHUB_OUTPUT"
529
+
530
+ - name: Cache global npm packages
531
+ id: npm-cache
532
+ uses: actions/cache@v4
533
+ with:
534
+ path: \${{ steps.npm-prefix.outputs.dir }}
535
+ key: npm-global-\${{ runner.os }}-whitesmith-v1
536
+
537
+ - name: Install whitesmith
538
+ if: steps.npm-cache.outputs.cache-hit != 'true'
539
+ run: npm install -g whitesmith
540
+
541
+ - name: Reconcile
542
+ env:
543
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
544
+ run: whitesmith reconcile .
545
+ `;
546
+ }
547
+ // ─── Refresh OAuth Script (auth-json mode only) ─────────────────────────────
548
+ const REFRESH_OAUTH_SCRIPT = `\
549
+ #!/usr/bin/env node
550
+ /**
551
+ * Refresh OAuth tokens in pi's auth.json before pi runs.
552
+ *
553
+ * Workaround for https://github.com/badlogic/pi-mono/issues/2743
554
+ * pi-ai sends JSON to Anthropic's OAuth token endpoint, which now requires
555
+ * application/x-www-form-urlencoded. We refresh the token ourselves.
556
+ *
557
+ * After refreshing, updates the PI_AUTH_JSON GitHub secret so the next run
558
+ * has the latest rotated refresh token (requires GH_PAT with repo scope).
559
+ *
560
+ * Remove this script once the upstream fix is released.
561
+ */
562
+ import { readFileSync, writeFileSync, chmodSync } from "fs";
563
+ import { join } from "path";
564
+ import { execSync } from "child_process";
565
+
566
+ const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
567
+ const ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
568
+
569
+ const authPath = join(process.env.HOME, ".pi", "agent", "auth.json");
570
+ const auth = JSON.parse(readFileSync(authPath, "utf-8"));
571
+ const cred = auth.anthropic;
572
+
573
+ if (!cred || cred.type !== "oauth") {
574
+ console.log("No OAuth credentials for anthropic, skipping refresh");
575
+ process.exit(0);
576
+ }
577
+
578
+ if (Date.now() < cred.expires) {
579
+ console.log("Token still valid until", new Date(cred.expires).toISOString());
580
+ process.exit(0);
581
+ }
582
+
583
+ console.log(
584
+ "Token expired at",
585
+ new Date(cred.expires).toISOString(),
586
+ "- refreshing..."
587
+ );
588
+
589
+ const response = await fetch(ANTHROPIC_TOKEN_URL, {
590
+ method: "POST",
591
+ headers: {
592
+ "Content-Type": "application/x-www-form-urlencoded",
593
+ Accept: "application/json",
594
+ },
595
+ body: new URLSearchParams({
596
+ grant_type: "refresh_token",
597
+ client_id: ANTHROPIC_CLIENT_ID,
598
+ refresh_token: cred.refresh,
599
+ }).toString(),
600
+ signal: AbortSignal.timeout(30_000),
601
+ });
602
+
603
+ const data = await response.json();
604
+
605
+ if (!response.ok) {
606
+ console.error("Refresh failed:", response.status, JSON.stringify(data));
607
+ process.exit(1);
608
+ }
609
+
610
+ auth.anthropic = {
611
+ type: "oauth",
612
+ refresh: data.refresh_token,
613
+ access: data.access_token,
614
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
615
+ };
616
+
617
+ writeFileSync(authPath, JSON.stringify(auth, null, 2));
618
+ chmodSync(authPath, 0o600);
619
+ console.log(
620
+ "Token refreshed, new expiry:",
621
+ new Date(auth.anthropic.expires).toISOString()
622
+ );
623
+
624
+ // Update the GitHub secret so the next run has the latest refresh token
625
+ const repo = process.env.GITHUB_REPOSITORY;
626
+ const token = process.env.GH_PAT;
627
+ if (repo && token) {
628
+ try {
629
+ execSync(\`gh secret set PI_AUTH_JSON --repo "\${repo}"\`, {
630
+ input: JSON.stringify(auth),
631
+ env: { ...process.env, GH_TOKEN: token },
632
+ stdio: ["pipe", "pipe", "pipe"],
633
+ });
634
+ console.log("PI_AUTH_JSON secret updated");
635
+ } catch (err) {
636
+ console.warn("Failed to update secret (non-fatal):", err.stderr?.toString() || err.message);
637
+ }
638
+ } else {
639
+ console.log("Skipping secret update (no GH_PAT or GITHUB_REPOSITORY)");
640
+ }
641
+ `;
642
+ export async function installCI(workDir, options) {
643
+ const { authMode } = options;
644
+ console.log('=== whitesmith install-ci ===\n');
645
+ console.log(`Auth mode: ${authMode}\n`);
646
+ // Detect repo for setting secrets
647
+ let repo = options.repo || detectRepo(workDir);
648
+ const hasGh = ghIsAvailable();
649
+ if (!repo && authMode === 'models-json' && hasGh) {
650
+ repo = await input({
651
+ message: 'GitHub repository (owner/repo) — needed to set secrets:',
652
+ });
653
+ }
654
+ let providers = [];
655
+ let defaultProvider;
656
+ let defaultModel;
657
+ if (authMode === 'models-json') {
658
+ // Configure providers interactively
659
+ providers = await promptProviders();
660
+ // Pick defaults
661
+ const defaults = await promptDefaults(providers);
662
+ defaultProvider = defaults.provider;
663
+ defaultModel = defaults.model;
664
+ }
665
+ else {
666
+ // auth.json mode — still need provider/model for whitesmith commands
667
+ defaultProvider = await input({
668
+ message: 'Default AI provider:',
669
+ default: 'anthropic',
670
+ });
671
+ defaultModel = await input({
672
+ message: 'Default AI model:',
673
+ default: 'claude-sonnet-4-20250514',
674
+ });
675
+ }
676
+ const config = {
677
+ authMode,
678
+ providers,
679
+ defaultProvider,
680
+ defaultModel,
681
+ };
682
+ // ── Set GitHub secrets via gh CLI ─────────────────────────────────────
683
+ if (authMode === 'models-json' && repo) {
684
+ if (!hasGh) {
685
+ console.log('\n⚠ GitHub CLI (gh) is not available or not authenticated.');
686
+ console.log(' You will need to set the following secrets manually.\n');
687
+ }
688
+ else {
689
+ console.log('\n🔑 Setting API key secrets on GitHub...\n');
690
+ const setSecrets = await promptAndSetSecrets(repo, providers);
691
+ const allEnvVars = [...new Set(providers.map((p) => p.apiKeyEnvVar))];
692
+ const missing = allEnvVars.filter((v) => !setSecrets.includes(v));
693
+ if (missing.length > 0) {
694
+ console.log(`\n⚠ The following secrets were not set and must be added manually:`);
695
+ for (const m of missing) {
696
+ console.log(` - ${m}`);
697
+ }
698
+ }
699
+ }
700
+ }
701
+ // ── Generate and write workflow files ─────────────────────────────────
702
+ const githubDir = path.join(workDir, '.github');
703
+ const workflowsDir = path.join(githubDir, 'workflows');
704
+ fs.mkdirSync(workflowsDir, { recursive: true });
705
+ const files = [
706
+ {
707
+ path: path.join(workflowsDir, 'whitesmith.yml'),
708
+ content: generateMainWorkflow(config),
709
+ },
710
+ {
711
+ path: path.join(workflowsDir, 'whitesmith-comment.yml'),
712
+ content: generateCommentWorkflow(config),
713
+ },
714
+ {
715
+ path: path.join(workflowsDir, 'whitesmith-reconcile.yml'),
716
+ content: generateReconcileWorkflow(),
717
+ },
718
+ ];
719
+ if (authMode === 'auth-json') {
720
+ const scriptsDir = path.join(githubDir, 'scripts');
721
+ fs.mkdirSync(scriptsDir, { recursive: true });
722
+ files.push({
723
+ path: path.join(scriptsDir, 'refresh-oauth-token.mjs'),
724
+ content: REFRESH_OAUTH_SCRIPT,
725
+ });
726
+ }
727
+ for (const file of files) {
728
+ fs.writeFileSync(file.path, file.content, 'utf-8');
729
+ }
730
+ // ── Summary ──────────────────────────────────────────────────────────
731
+ console.log('\n✅ GitHub Actions workflows installed!\n');
732
+ console.log('Files created:');
733
+ for (const file of files) {
734
+ console.log(` ${path.relative(workDir, file.path)}`);
735
+ }
736
+ console.log('\n📋 Required setup:\n');
737
+ console.log('1. Enable in repo settings:');
738
+ console.log(' Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"\n');
739
+ if (authMode === 'auth-json') {
740
+ console.log('2. Add GitHub secrets:');
741
+ console.log(' - PI_AUTH_JSON: contents of ~/.pi/agent/auth.json');
742
+ console.log(' - GH_PAT: GitHub personal access token with repo scope (for OAuth token refresh)\n');
743
+ }
744
+ else if (!hasGh || !repo) {
745
+ console.log('2. Add GitHub secrets for your API keys:');
746
+ const seen = new Set();
747
+ for (const p of providers) {
748
+ if (!seen.has(p.apiKeyEnvVar)) {
749
+ console.log(` - ${p.apiKeyEnvVar}: API key for ${p.name}`);
750
+ seen.add(p.apiKeyEnvVar);
751
+ }
752
+ }
753
+ console.log('');
754
+ }
755
+ console.log(`Default provider: ${defaultProvider}`);
756
+ console.log(`Default model: ${defaultModel}`);
757
+ console.log('');
758
+ console.log('You can customize these by editing the generated workflow files.');
759
+ }
760
+ //# sourceMappingURL=install-ci.js.map