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