whitesmith 0.0.2 → 0.0.4

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 (50) hide show
  1. package/README.md +286 -88
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +90 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/comment.d.ts.map +1 -1
  6. package/dist/comment.js +18 -11
  7. package/dist/comment.js.map +1 -1
  8. package/dist/git.d.ts +5 -3
  9. package/dist/git.d.ts.map +1 -1
  10. package/dist/git.js +20 -29
  11. package/dist/git.js.map +1 -1
  12. package/dist/harnesses/pi.d.ts.map +1 -1
  13. package/dist/harnesses/pi.js +22 -6
  14. package/dist/harnesses/pi.js.map +1 -1
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestrator.d.ts +31 -3
  20. package/dist/orchestrator.d.ts.map +1 -1
  21. package/dist/orchestrator.js +214 -10
  22. package/dist/orchestrator.js.map +1 -1
  23. package/dist/prompts.d.ts +52 -0
  24. package/dist/prompts.d.ts.map +1 -1
  25. package/dist/prompts.js +197 -0
  26. package/dist/prompts.js.map +1 -1
  27. package/dist/providers/github-ci.d.ts +40 -0
  28. package/dist/providers/github-ci.d.ts.map +1 -1
  29. package/dist/providers/github-ci.js +463 -213
  30. package/dist/providers/github-ci.js.map +1 -1
  31. package/dist/providers/index.d.ts +1 -1
  32. package/dist/providers/index.d.ts.map +1 -1
  33. package/dist/review.d.ts +48 -0
  34. package/dist/review.d.ts.map +1 -0
  35. package/dist/review.js +221 -0
  36. package/dist/review.js.map +1 -0
  37. package/dist/types.d.ts +4 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli.ts +116 -3
  41. package/src/comment.ts +20 -14
  42. package/src/git.ts +23 -30
  43. package/src/harnesses/pi.ts +27 -6
  44. package/src/index.ts +9 -1
  45. package/src/orchestrator.ts +253 -14
  46. package/src/prompts.ts +239 -0
  47. package/src/providers/github-ci.ts +513 -217
  48. package/src/providers/index.ts +1 -1
  49. package/src/review.ts +290 -0
  50. package/src/types.ts +4 -0
@@ -6,7 +6,7 @@ import * as path from 'node:path';
6
6
 
7
7
  export type AuthMode = 'auth-json' | 'models-json';
8
8
 
9
- interface ProviderEntry {
9
+ export interface ProviderEntry {
10
10
  name: string;
11
11
  baseUrl?: string;
12
12
  api?: string;
@@ -16,11 +16,33 @@ interface ProviderEntry {
16
16
  builtin: boolean;
17
17
  }
18
18
 
19
+ /**
20
+ * Serializable CI configuration.
21
+ * Can be saved to JSON with --export-config and loaded with --config.
22
+ * When --include-secrets is used, the `secrets` field maps env var names to
23
+ * their actual API key values. These are set via `gh secret set` on install.
24
+ */
25
+ export interface CIConfigFile {
26
+ providers: ProviderEntry[];
27
+ defaultProvider: string;
28
+ defaultModel: string;
29
+ /** API key values keyed by env var name. Only present with --include-secrets. */
30
+ secrets?: Record<string, string>;
31
+ }
32
+
19
33
  interface CIConfig {
20
34
  authMode: AuthMode;
21
35
  providers: ProviderEntry[];
22
36
  defaultProvider: string;
23
37
  defaultModel: string;
38
+ /** When true, install whitesmith from source (pnpm i + pnpm link --global) instead of npm. */
39
+ dev: boolean;
40
+ /** When true, generate the review workflow. */
41
+ reviewWorkflow: boolean;
42
+ /** When true, the review step is enabled in the main loop (whitesmith PRs are already reviewed inline). */
43
+ reviewStepEnabled: boolean;
44
+ /** whitesmith package version to pin in npm install. */
45
+ version: string;
24
46
  }
25
47
 
26
48
  /**
@@ -180,12 +202,14 @@ async function promptDefaults(
180
202
  }
181
203
 
182
204
  /**
183
- * Prompt for API key values and set them as GitHub secrets.
184
- * Returns the list of secrets that were set.
205
+ * Set API key secrets on GitHub. If `knownSecrets` contains a value for an
206
+ * env var, it is used directly; otherwise the user is prompted interactively.
207
+ * Returns the list of secret names that were successfully set.
185
208
  */
186
- async function promptAndSetSecrets(
209
+ async function setOrPromptSecrets(
187
210
  ctx: GitHubCIContext,
188
211
  providers: ProviderEntry[],
212
+ knownSecrets?: Record<string, string>,
189
213
  ): Promise<string[]> {
190
214
  const setSecrets: string[] = [];
191
215
  const seen = new Set<string>();
@@ -194,9 +218,12 @@ async function promptAndSetSecrets(
194
218
  if (seen.has(p.apiKeyEnvVar)) continue;
195
219
  seen.add(p.apiKeyEnvVar);
196
220
 
197
- const apiKey = await password({
198
- message: `Enter API key for ${p.name} (secret: ${p.apiKeyEnvVar}):`,
199
- });
221
+ let apiKey = knownSecrets?.[p.apiKeyEnvVar];
222
+ if (!apiKey) {
223
+ apiKey = await password({
224
+ message: `Enter API key for ${p.name} (secret: ${p.apiKeyEnvVar}):`,
225
+ });
226
+ }
200
227
 
201
228
  if (!apiKey) {
202
229
  console.log(` ⚠ Skipped ${p.apiKeyEnvVar} (empty)`);
@@ -252,94 +279,164 @@ function indent(text: string, spaces: number): string {
252
279
  .join('\n');
253
280
  }
254
281
 
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
- }
282
+ /**
283
+ * Top-level env block shared by whitesmith.yml and whitesmith-comment.yml.
284
+ * Includes defaults, GH_TOKEN, and API key secrets.
285
+ */
286
+ function generateTopLevelEnv(config: CIConfig): string {
287
+ const lines: string[] = [
288
+ ` WHITESMITH_PROVIDER: ${config.defaultProvider}`,
289
+ ` WHITESMITH_MODEL: ${config.defaultModel}`,
290
+ ` GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}`,
291
+ ];
267
292
 
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
293
+ if (config.authMode === 'models-json') {
294
+ const seen = new Set<string>();
295
+ for (const p of config.providers) {
296
+ if (seen.has(p.apiKeyEnvVar)) continue;
297
+ seen.add(p.apiKeyEnvVar);
298
+ lines.push(` ${p.apiKeyEnvVar}: \${{ secrets.${p.apiKeyEnvVar} }}`);
299
+ }
300
+ }
282
301
 
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`;
302
+ return lines.join('\n');
288
303
  }
289
304
 
290
- function generateAuthSteps(config: CIConfig): string {
305
+ /**
306
+ * Composite action: node setup, git config, npm cache, install, auth config.
307
+ * This is written to .github/actions/setup-whitesmith/action.yml so workflows
308
+ * can just do `uses: ./.github/actions/setup-whitesmith`.
309
+ */
310
+ function generateSetupAction(config: CIConfig): string {
311
+ let authStep: string;
312
+
291
313
  if (config.authMode === 'auth-json') {
292
- return generateAuthJsonSteps();
314
+ authStep = `\
315
+ - name: Configure pi auth
316
+ shell: bash
317
+ run: |
318
+ if [ -z "$PI_AUTH_JSON" ]; then
319
+ echo "ERROR: PI_AUTH_JSON secret is not set" >&2; exit 1
320
+ fi
321
+ mkdir -p ~/.pi/agent
322
+ echo "$PI_AUTH_JSON" > ~/.pi/agent/auth.json
323
+ chmod 600 ~/.pi/agent/auth.json
324
+
325
+ # Workaround for https://github.com/badlogic/pi-mono/issues/2743
326
+ - name: Refresh OAuth token
327
+ shell: bash
328
+ run: node .github/scripts/refresh-oauth-token.mjs`;
329
+ } else {
330
+ const modelsJson = buildModelsJson(config.providers);
331
+ const modelsJsonStr = JSON.stringify(modelsJson, null, 2);
332
+
333
+ authStep = `\
334
+ - name: Configure pi models
335
+ shell: bash
336
+ run: |
337
+ mkdir -p ~/.pi/agent
338
+ cat > ~/.pi/agent/models.json << 'MODELS_EOF'
339
+ ${indent(modelsJsonStr, 8)}
340
+ MODELS_EOF`;
293
341
  }
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
342
 
302
- if (config.authMode === 'models-json') {
303
- for (const p of config.providers) {
304
- envs[p.apiKeyEnvVar] = `\${{ secrets.${p.apiKeyEnvVar} }}`;
305
- }
343
+ let installSteps: string;
344
+
345
+ if (config.dev) {
346
+ // Dev mode: build whitesmith from source using pnpm.
347
+ // We add pnpm's global bin to $GITHUB_PATH so that `whitesmith` and `pi`
348
+ // are available in all subsequent steps (persists across composite action
349
+ // steps and the calling workflow).
350
+ // We always rebuild (even on cache hit) because source changes per commit.
351
+ installSteps = `\
352
+ - name: Setup pnpm
353
+ uses: pnpm/action-setup@v4
354
+
355
+ - name: Add pnpm global bin to PATH
356
+ shell: bash
357
+ run: |
358
+ pnpm setup
359
+ echo "$HOME/.local/share/pnpm" >> "$GITHUB_PATH"
360
+
361
+ - name: Install dependencies and build whitesmith
362
+ shell: bash
363
+ run: |
364
+ pnpm install
365
+ pnpm run build
366
+ pnpm link --global
367
+
368
+ - name: Install pi
369
+ shell: bash
370
+ run: pnpm add -g @mariozechner/pi-coding-agent`;
371
+ } else {
372
+ installSteps = `\
373
+ - name: Get npm global prefix
374
+ id: npm-prefix
375
+ shell: bash
376
+ run: echo "dir=$(npm prefix -g)" >> "$GITHUB_OUTPUT"
377
+
378
+ - name: Cache npm packages
379
+ id: npm-cache
380
+ uses: actions/cache@v4
381
+ with:
382
+ path: \${{ steps.npm-prefix.outputs.dir }}
383
+ key: whitesmith-\${{ runner.os }}-${config.version}
384
+
385
+ - name: Install whitesmith and pi
386
+ if: steps.npm-cache.outputs.cache-hit != 'true'
387
+ shell: bash
388
+ run: npm install -g whitesmith@${config.version} @mariozechner/pi-coding-agent`;
306
389
  }
307
390
 
308
- return Object.entries(envs)
309
- .map(([k, v]) => ` ${k}: ${v}`)
310
- .join('\n');
391
+ return `\
392
+ name: Setup whitesmith
393
+ description: Install Node.js, whitesmith, pi, and configure AI provider auth
394
+
395
+ runs:
396
+ using: composite
397
+ steps:
398
+ - name: Setup Node.js
399
+ uses: actions/setup-node@v4
400
+ with:
401
+ node-version: '22'
402
+
403
+ - name: Configure git
404
+ shell: bash
405
+ run: |
406
+ git config user.name "whitesmith[bot]"
407
+ git config user.email "whitesmith[bot]@users.noreply.github.com"
408
+
409
+ ${installSteps}
410
+
411
+ ${authStep}
412
+ `;
311
413
  }
312
414
 
313
415
  function generateMainWorkflow(config: CIConfig): string {
314
- const authSteps = generateAuthSteps(config);
315
- const envBlock = generateRunEnvBlock(config);
416
+ const envBlock = generateTopLevelEnv(config);
316
417
 
317
418
  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.
419
+ # Requires: Settings Actions General → "Allow GitHub Actions to create and approve pull requests"
321
420
  name: whitesmith
322
421
 
323
422
  on:
324
- schedule:
325
- - cron: '*/15 * * * *'
326
423
  workflow_dispatch:
327
424
  inputs:
425
+ issue:
426
+ description: 'Issue number to target (leave empty for global scan)'
328
427
  max_iterations:
329
428
  description: 'Maximum iterations'
330
429
  default: '3'
331
- type: string
332
430
  provider:
333
- description: 'AI provider (e.g. ${config.defaultProvider})'
334
- required: false
335
- type: string
431
+ description: 'AI provider (overrides WHITESMITH_PROVIDER)'
336
432
  model:
337
- description: 'AI model ID (e.g. ${config.defaultModel})'
338
- required: false
339
- type: string
433
+ description: 'AI model (overrides WHITESMITH_MODEL)'
434
+
435
+ env:
436
+ ${envBlock}
340
437
 
341
438
  concurrency:
342
- group: whitesmith-loop
439
+ group: \${{ inputs.issue && format('whitesmith-issue-{0}', inputs.issue) || 'whitesmith-global' }}
343
440
  cancel-in-progress: false
344
441
 
345
442
  permissions:
@@ -354,64 +451,36 @@ jobs:
354
451
  - uses: actions/checkout@v4
355
452
  with:
356
453
  fetch-depth: 0
357
- token: \${{ secrets.GITHUB_TOKEN }}
358
454
 
359
- - name: Setup Node.js
360
- uses: actions/setup-node@v4
361
- with:
362
- node-version: '22'
455
+ - uses: ./.github/actions/setup-whitesmith
363
456
 
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}' }}"
457
+ - run: |
458
+ ISSUE_FLAG=""
459
+ if [ -n "\${{ inputs.issue }}" ]; then
460
+ ISSUE_FLAG="--issue \${{ inputs.issue }}"
461
+ fi
394
462
  whitesmith run . \\
395
- --agent-cmd "pi" \\
396
- --provider "$PROVIDER" \\
397
- --model "$MODEL" \\
463
+ \$ISSUE_FLAG \\
464
+ --provider "\${{ inputs.provider || env.WHITESMITH_PROVIDER }}" \\
465
+ --model "\${{ inputs.model || env.WHITESMITH_MODEL }}" \\
398
466
  --max-iterations \${{ inputs.max_iterations || '3' }}
399
467
  `;
400
468
  }
401
469
 
402
470
  function generateCommentWorkflow(config: CIConfig): string {
403
- const authSteps = generateAuthSteps(config);
404
- const envBlock = generateRunEnvBlock(config);
471
+ const envBlock = generateTopLevelEnv(config);
405
472
 
406
473
  return `\
407
- # NOTE: This workflow requires the repo setting:
408
- # Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"
474
+ # Requires: Settings Actions General → "Allow GitHub Actions to create and approve pull requests"
409
475
  name: whitesmith-comment
410
476
 
411
477
  on:
412
478
  issue_comment:
413
479
  types: [created]
414
480
 
481
+ env:
482
+ ${envBlock}
483
+
415
484
  concurrency:
416
485
  group: whitesmith-comment-\${{ github.event.issue.number }}
417
486
  cancel-in-progress: false
@@ -427,120 +496,203 @@ jobs:
427
496
  outputs:
428
497
  should_run: \${{ steps.check.outputs.should_run }}
429
498
  steps:
430
- - name: Check if should run
431
- id: check
499
+ - id: check
432
500
  env:
433
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
434
501
  COMMENT_BODY: \${{ github.event.comment.body }}
435
502
  run: |
436
- # Always run if comment contains /whitesmith
437
503
  if echo "$COMMENT_BODY" | grep -q '/whitesmith'; then
438
504
  echo "should_run=true" >> "$GITHUB_OUTPUT"
439
- echo "Triggered by /whitesmith keyword"
440
505
  exit 0
441
506
  fi
442
-
443
- # For PR comments, auto-trigger if the PR is on a whitesmith branch
444
507
  if [ -n "\${{ github.event.issue.pull_request.url }}" ]; then
445
508
  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
509
+ --repo \${{ github.repository }} --json headRefName -q .headRefName)
510
+ if echo "$BRANCH" | grep -qE '^(investigate|issue)/'; then
450
511
  echo "should_run=true" >> "$GITHUB_OUTPUT"
451
- echo "Triggered by comment on whitesmith PR branch"
452
512
  exit 0
453
513
  fi
454
514
  fi
455
-
456
515
  echo "should_run=false" >> "$GITHUB_OUTPUT"
457
- echo "Skipping: not a /whitesmith command and not a whitesmith PR"
458
516
 
459
517
  run:
460
518
  needs: check
461
- runs-on: ubuntu-latest
462
519
  if: needs.check.outputs.should_run == 'true'
520
+ runs-on: ubuntu-latest
463
521
  steps:
464
- - name: React with eyes to acknowledge
465
- env:
466
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
467
- run: |
522
+ - run: |
468
523
  gh api repos/\${{ github.repository }}/issues/comments/\${{ github.event.comment.id }}/reactions \\
469
524
  -f content=eyes
470
525
 
471
526
  - uses: actions/checkout@v4
472
527
  with:
473
528
  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
529
 
486
- - name: Get npm global prefix
487
- id: npm-prefix
488
- run: echo "dir=$(npm prefix -g)" >> "$GITHUB_OUTPUT"
530
+ - uses: ./.github/actions/setup-whitesmith
489
531
 
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:
532
+ - env:
507
533
  COMMENT_BODY: \${{ github.event.comment.body }}
508
534
  run: |
509
535
  printf '%s' "$COMMENT_BODY" > .whitesmith-comment-body.txt
510
-
511
- - name: Run whitesmith comment
512
- env:
513
- ${envBlock}
514
- run: |
515
536
  whitesmith comment . \\
516
537
  --number "\${{ github.event.issue.number }}" \\
517
538
  --body-file .whitesmith-comment-body.txt \\
518
- --provider "${config.defaultProvider}" \\
519
- --model "${config.defaultModel}" \\
539
+ --provider "$WHITESMITH_PROVIDER" \\
540
+ --model "$WHITESMITH_MODEL" \\
520
541
  --post
521
542
 
522
- - name: React with checkmark on success
523
- if: success()
524
- env:
525
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
543
+ - if: success()
526
544
  run: |
527
545
  gh api repos/\${{ github.repository }}/issues/comments/\${{ github.event.comment.id }}/reactions \\
528
546
  -f content="+1"
529
547
 
530
- - name: React with X and comment on failure
531
- if: failure()
532
- env:
533
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
548
+ - if: failure()
534
549
  run: |
535
550
  gh api repos/\${{ github.repository }}/issues/comments/\${{ github.event.comment.id }}/reactions \\
536
551
  -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."
552
+ gh issue comment \${{ github.event.issue.number }} --repo \${{ github.repository }} \\
553
+ --body "❌ Agent run failed. See [workflow run](\${{ github.server_url }}/\${{ github.repository }}/actions/runs/\${{ github.run_id }})."
540
554
  `;
541
555
  }
542
556
 
543
- function generateReconcileWorkflow(): string {
557
+ function generateReviewWorkflow(config: CIConfig): string {
558
+ const envBlock = generateTopLevelEnv(config);
559
+
560
+ // When the review step is enabled in the main loop, whitesmith PRs are
561
+ // already reviewed inline. The workflow should only review non-whitesmith PRs.
562
+ // When the review step is disabled, the workflow reviews ALL PRs.
563
+ const skipWhitesmithCheck = config.reviewStepEnabled
564
+ ? `\
565
+ check:
566
+ if: github.event_name == 'pull_request'
567
+ runs-on: ubuntu-latest
568
+ outputs:
569
+ should_run: \${{ steps.check.outputs.should_run }}
570
+ steps:
571
+ - id: check
572
+ run: |
573
+ BRANCH="\${{ github.event.pull_request.head.ref }}"
574
+ if echo "$BRANCH" | grep -qE '^(investigate|issue)/'; then
575
+ echo "Skipping review for whitesmith-managed branch: $BRANCH"
576
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
577
+ else
578
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
579
+ fi
580
+
581
+ review:
582
+ needs: check
583
+ if: >-
584
+ (github.event_name == 'workflow_dispatch') ||
585
+ (needs.check.outputs.should_run == 'true')`
586
+ : `\
587
+ review:`;
588
+
589
+ return `\
590
+ name: whitesmith-review
591
+
592
+ on:
593
+ pull_request:
594
+ types: [opened, synchronize]
595
+ workflow_dispatch:
596
+ inputs:
597
+ number:
598
+ description: 'PR or issue number to review'
599
+ required: true
600
+ type:
601
+ description: 'Review type (auto-detected if empty): pr, issue-tasks, issue-tasks-completed'
602
+ provider:
603
+ description: 'AI provider (overrides WHITESMITH_PROVIDER)'
604
+ model:
605
+ description: 'AI model (overrides WHITESMITH_MODEL)'
606
+
607
+ env:
608
+ ${envBlock}
609
+
610
+ concurrency:
611
+ group: whitesmith-review-\${{ github.event.pull_request.number || inputs.number }}
612
+ cancel-in-progress: true
613
+
614
+ permissions:
615
+ contents: read
616
+ issues: write
617
+ pull-requests: write
618
+
619
+ jobs:
620
+ ${skipWhitesmithCheck}
621
+ runs-on: ubuntu-latest
622
+ steps:
623
+ - uses: actions/checkout@v4
624
+ with:
625
+ fetch-depth: 0
626
+
627
+ - uses: ./.github/actions/setup-whitesmith
628
+
629
+ - if: github.event_name == 'pull_request'
630
+ run: |
631
+ whitesmith review . \\
632
+ --number "\${{ github.event.pull_request.number }}" \\
633
+ --provider "\${{ env.WHITESMITH_PROVIDER }}" \\
634
+ --model "\${{ env.WHITESMITH_MODEL }}" \\
635
+ --post
636
+
637
+ - if: github.event_name == 'workflow_dispatch'
638
+ run: |
639
+ TYPE_FLAG=""
640
+ if [ -n "\${{ inputs.type }}" ]; then
641
+ TYPE_FLAG="--type \${{ inputs.type }}"
642
+ fi
643
+ whitesmith review . \\
644
+ --number "\${{ inputs.number }}" \\
645
+ \$TYPE_FLAG \\
646
+ --provider "\${{ inputs.provider || env.WHITESMITH_PROVIDER }}" \\
647
+ --model "\${{ inputs.model || env.WHITESMITH_MODEL }}" \\
648
+ --post
649
+ `;
650
+ }
651
+
652
+ function generateIssueWorkflow(config: CIConfig): string {
653
+ const envBlock = generateTopLevelEnv(config);
654
+
655
+ return `\
656
+ name: whitesmith-issue
657
+
658
+ on:
659
+ issues:
660
+ types: [opened]
661
+
662
+ env:
663
+ ${envBlock}
664
+
665
+ concurrency:
666
+ group: whitesmith-issue-\${{ github.event.issue.number }}
667
+ cancel-in-progress: false
668
+
669
+ permissions:
670
+ contents: write
671
+ issues: write
672
+ pull-requests: write
673
+
674
+ jobs:
675
+ run:
676
+ runs-on: ubuntu-latest
677
+ steps:
678
+ - uses: actions/checkout@v4
679
+ with:
680
+ fetch-depth: 0
681
+
682
+ - uses: ./.github/actions/setup-whitesmith
683
+
684
+ - run: |
685
+ whitesmith run . \\
686
+ --issue "\${{ github.event.issue.number }}" \\
687
+ --provider "$WHITESMITH_PROVIDER" \\
688
+ --model "$WHITESMITH_MODEL" \\
689
+ --max-iterations 10
690
+ `;
691
+ }
692
+
693
+ function generateReconcileWorkflow(config: CIConfig): string {
694
+ const envBlock = generateTopLevelEnv(config);
695
+
544
696
  return `\
545
697
  name: whitesmith-reconcile
546
698
 
@@ -549,42 +701,71 @@ on:
549
701
  types: [closed]
550
702
  branches: [main]
551
703
 
704
+ env:
705
+ ${envBlock}
706
+
552
707
  permissions:
553
- contents: read
708
+ contents: write
554
709
  issues: write
555
- pull-requests: read
710
+ pull-requests: write
556
711
 
557
712
  jobs:
558
- reconcile:
713
+ parse:
559
714
  if: github.event.pull_request.merged == true
560
715
  runs-on: ubuntu-latest
716
+ outputs:
717
+ issue_number: \${{ steps.parse.outputs.issue_number }}
718
+ branch_type: \${{ steps.parse.outputs.branch_type }}
561
719
  steps:
562
- - uses: actions/checkout@v4
720
+ - id: parse
721
+ run: |
722
+ BRANCH="\${{ github.event.pull_request.head.ref }}"
723
+ INVESTIGATE_NUM=$(echo "$BRANCH" | sed -n 's|^investigate/\\([0-9]*\\)$|\\1|p')
724
+ ISSUE_NUM=$(echo "$BRANCH" | sed -n 's|^issue/\\([0-9]*\\)$|\\1|p')
725
+ if [ -n "$INVESTIGATE_NUM" ]; then
726
+ echo "issue_number=$INVESTIGATE_NUM" >> "$GITHUB_OUTPUT"
727
+ echo "branch_type=investigate" >> "$GITHUB_OUTPUT"
728
+ elif [ -n "$ISSUE_NUM" ]; then
729
+ echo "issue_number=$ISSUE_NUM" >> "$GITHUB_OUTPUT"
730
+ echo "branch_type=issue" >> "$GITHUB_OUTPUT"
731
+ else
732
+ echo "branch_type=other" >> "$GITHUB_OUTPUT"
733
+ fi
563
734
 
564
- - name: Setup Node.js
565
- uses: actions/setup-node@v4
735
+ implement:
736
+ needs: parse
737
+ if: needs.parse.outputs.branch_type == 'investigate'
738
+ runs-on: ubuntu-latest
739
+ concurrency:
740
+ group: whitesmith-issue-\${{ needs.parse.outputs.issue_number }}
741
+ cancel-in-progress: false
742
+ steps:
743
+ - uses: actions/checkout@v4
566
744
  with:
567
- node-version: '22'
745
+ fetch-depth: 0
568
746
 
569
- - name: Get npm global prefix
570
- id: npm-prefix
571
- run: echo "dir=$(npm prefix -g)" >> "$GITHUB_OUTPUT"
747
+ - uses: ./.github/actions/setup-whitesmith
572
748
 
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
749
+ - run: |
750
+ whitesmith run . \\
751
+ --issue "\${{ needs.parse.outputs.issue_number }}" \\
752
+ --provider "$WHITESMITH_PROVIDER" \\
753
+ --model "$WHITESMITH_MODEL" \\
754
+ --max-iterations 10
579
755
 
580
- - name: Install whitesmith
581
- if: steps.npm-cache.outputs.cache-hit != 'true'
582
- run: npm install -g whitesmith
756
+ reconcile:
757
+ needs: parse
758
+ if: needs.parse.outputs.branch_type != 'investigate'
759
+ runs-on: ubuntu-latest
760
+ concurrency:
761
+ group: \${{ (needs.parse.outputs.issue_number && format('whitesmith-issue-{0}', needs.parse.outputs.issue_number)) || 'whitesmith-reconcile-other' }}
762
+ cancel-in-progress: false
763
+ steps:
764
+ - uses: actions/checkout@v4
583
765
 
584
- - name: Reconcile
585
- env:
586
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
587
- run: whitesmith reconcile .
766
+ - uses: ./.github/actions/setup-whitesmith
767
+
768
+ - run: whitesmith reconcile .
588
769
  `;
589
770
  }
590
771
 
@@ -690,6 +871,42 @@ if (repo && token) {
690
871
  export interface InstallCIOptions {
691
872
  authMode: AuthMode;
692
873
  fake?: boolean;
874
+ /** Path to a JSON config file — skips interactive prompts. */
875
+ configFile?: string;
876
+ /** Write the provider config as JSON to this file path instead of generating workflows. */
877
+ exportConfig?: string;
878
+ /** When used with --export-config, prompt for API keys and include them in the output. */
879
+ includeSecrets?: boolean;
880
+ /** Build whitesmith from source (pnpm i + link --global) instead of installing from npm. Auto-detected when inside the whitesmith repo. */
881
+ dev?: boolean;
882
+ /** Generate the review workflow for PR reviews. Off by default. */
883
+ reviewWorkflow?: boolean;
884
+ /** Whether the review step is enabled in the main loop (affects review workflow filtering). */
885
+ reviewStepEnabled?: boolean;
886
+ /** Skip setting GitHub secrets (useful when reconfiguring workflows only). */
887
+ skipSecrets?: boolean;
888
+ /** whitesmith package version to pin in the install command. */
889
+ version: string;
890
+ }
891
+
892
+ /**
893
+ * Load config from a JSON file, skipping interactive prompts.
894
+ */
895
+ function loadConfigFile(filePath: string): CIConfigFile {
896
+ const raw = fs.readFileSync(filePath, 'utf-8');
897
+ const data = JSON.parse(raw) as CIConfigFile;
898
+
899
+ if (!data.providers || !Array.isArray(data.providers) || data.providers.length === 0) {
900
+ throw new Error(`Config file must contain a non-empty "providers" array`);
901
+ }
902
+ if (!data.defaultProvider) {
903
+ throw new Error(`Config file must contain "defaultProvider"`);
904
+ }
905
+ if (!data.defaultModel) {
906
+ throw new Error(`Config file must contain "defaultModel"`);
907
+ }
908
+
909
+ return data;
693
910
  }
694
911
 
695
912
  export async function installGitHubCI(
@@ -697,33 +914,42 @@ export async function installGitHubCI(
697
914
  options: InstallCIOptions,
698
915
  ): Promise<void> {
699
916
  const {authMode} = options;
917
+ const fake = options.fake ?? false;
918
+ const exportConfig = options.exportConfig ?? undefined;
700
919
 
701
920
  console.log('=== whitesmith install-ci (GitHub) ===\n');
702
921
  console.log(`Auth mode: ${authMode}\n`);
703
922
 
704
923
  let repo = ctx.repo;
705
924
 
706
- if (!repo && authMode === 'models-json' && ctx.ghAvailable) {
925
+ if (!exportConfig && !fake && !repo && authMode === 'models-json' && ctx.ghAvailable) {
707
926
  repo = await input({
708
927
  message: 'GitHub repository (owner/repo) — needed to set secrets:',
709
928
  });
710
929
  ctx.repo = repo;
711
930
  }
712
931
 
713
- let providers: ProviderEntry[] = [];
932
+ let providers: ProviderEntry[];
714
933
  let defaultProvider: string;
715
934
  let defaultModel: string;
716
-
717
- if (authMode === 'models-json') {
718
- // Configure providers interactively
935
+ let loadedSecrets: Record<string, string> | undefined;
936
+
937
+ if (options.configFile) {
938
+ // Load from file — no prompts
939
+ const loaded = loadConfigFile(options.configFile);
940
+ providers = loaded.providers;
941
+ defaultProvider = loaded.defaultProvider;
942
+ defaultModel = loaded.defaultModel;
943
+ loadedSecrets = loaded.secrets;
944
+ } else if (authMode === 'models-json') {
945
+ // Interactive prompts
719
946
  providers = await promptProviders();
720
-
721
- // Pick defaults
722
947
  const defaults = await promptDefaults(providers);
723
948
  defaultProvider = defaults.provider;
724
949
  defaultModel = defaults.model;
725
950
  } else {
726
951
  // auth.json mode — still need provider/model for whitesmith commands
952
+ providers = [];
727
953
  defaultProvider = await input({
728
954
  message: 'Default AI provider:',
729
955
  default: 'anthropic',
@@ -734,24 +960,77 @@ export async function installGitHubCI(
734
960
  });
735
961
  }
736
962
 
963
+ // ── Export config mode — write JSON to stdout and exit ───────────────
964
+
965
+ if (exportConfig) {
966
+ const configFile: CIConfigFile = {providers, defaultProvider, defaultModel};
967
+ if (options.includeSecrets && providers.length > 0) {
968
+ const secrets: Record<string, string> = {};
969
+ const seen = new Set<string>();
970
+ for (const p of providers) {
971
+ if (seen.has(p.apiKeyEnvVar)) continue;
972
+ seen.add(p.apiKeyEnvVar);
973
+ const key = await password({
974
+ message: `Enter API key for ${p.name} (${p.apiKeyEnvVar}):`,
975
+ });
976
+ if (key) secrets[p.apiKeyEnvVar] = key;
977
+ }
978
+ if (Object.keys(secrets).length > 0) {
979
+ configFile.secrets = secrets;
980
+ }
981
+ }
982
+ const json = JSON.stringify(configFile, null, 2) + '\n';
983
+ fs.writeFileSync(exportConfig, json, 'utf-8');
984
+ console.log(`\n✅ Config written to ${exportConfig}`);
985
+ return;
986
+ }
987
+
988
+ // Auto-detect dev mode: check if we're inside the whitesmith repo itself
989
+ let dev = options.dev ?? false;
990
+ if (!dev) {
991
+ try {
992
+ const pkgPath = path.join(ctx.workDir, 'package.json');
993
+ if (fs.existsSync(pkgPath)) {
994
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
995
+ if (pkg.name === 'whitesmith') {
996
+ dev = true;
997
+ console.log('📦 Detected whitesmith repo — using dev mode (build from source)\n');
998
+ }
999
+ }
1000
+ } catch {
1001
+ // Ignore — not in whitesmith repo
1002
+ }
1003
+ }
1004
+
1005
+ const reviewWorkflow = options.reviewWorkflow ?? false;
1006
+ const reviewStepEnabled = options.reviewStepEnabled ?? true;
1007
+
737
1008
  const config: CIConfig = {
738
1009
  authMode,
739
1010
  providers,
740
1011
  defaultProvider,
741
1012
  defaultModel,
1013
+ dev,
1014
+ reviewWorkflow,
1015
+ reviewStepEnabled,
1016
+ version: options.version,
742
1017
  };
743
1018
 
744
1019
  // ── Set GitHub secrets via gh CLI ─────────────────────────────────────
745
1020
 
746
- const fake = options.fake ?? false;
1021
+ const skipSecrets = options.skipSecrets ?? false;
747
1022
 
748
- if (!fake && authMode === 'models-json' && repo) {
1023
+ if (skipSecrets) {
1024
+ console.log('\n🔑 Skipping secret setup (--no-secrets)');
1025
+ } else if (!fake && authMode === 'models-json' && repo) {
749
1026
  if (!ctx.ghAvailable) {
750
1027
  console.log('\n⚠ GitHub CLI (gh) is not available or not authenticated.');
751
1028
  console.log(' You will need to set the following secrets manually.\n');
752
1029
  } else {
1030
+ // If config file included secrets, set them directly without prompting
1031
+ const configSecrets = options.configFile ? loadedSecrets : undefined;
753
1032
  console.log('\n🔑 Setting API key secrets on GitHub...\n');
754
- const setSecrets = await promptAndSetSecrets(ctx, providers);
1033
+ const setSecrets = await setOrPromptSecrets(ctx, providers, configSecrets);
755
1034
 
756
1035
  const allEnvVars = [...new Set(providers.map((p) => p.apiKeyEnvVar))];
757
1036
  const missing = allEnvVars.filter((v) => !setSecrets.includes(v));
@@ -769,11 +1048,17 @@ export async function installGitHubCI(
769
1048
  // ── Generate and write workflow files ─────────────────────────────────
770
1049
 
771
1050
  const outputBase = fake ? '.fake' : '.github';
772
- const githubDir = path.join(ctx.workDir, outputBase);
773
- const workflowsDir = path.join(githubDir, 'workflows');
1051
+ const baseDir = path.join(ctx.workDir, outputBase);
1052
+ const workflowsDir = path.join(baseDir, 'workflows');
1053
+ const actionsDir = path.join(baseDir, 'actions', 'setup-whitesmith');
774
1054
  fs.mkdirSync(workflowsDir, {recursive: true});
1055
+ fs.mkdirSync(actionsDir, {recursive: true});
775
1056
 
776
1057
  const files: {path: string; content: string}[] = [
1058
+ {
1059
+ path: path.join(actionsDir, 'action.yml'),
1060
+ content: generateSetupAction(config),
1061
+ },
777
1062
  {
778
1063
  path: path.join(workflowsDir, 'whitesmith.yml'),
779
1064
  content: generateMainWorkflow(config),
@@ -782,14 +1067,25 @@ export async function installGitHubCI(
782
1067
  path: path.join(workflowsDir, 'whitesmith-comment.yml'),
783
1068
  content: generateCommentWorkflow(config),
784
1069
  },
1070
+ {
1071
+ path: path.join(workflowsDir, 'whitesmith-issue.yml'),
1072
+ content: generateIssueWorkflow(config),
1073
+ },
785
1074
  {
786
1075
  path: path.join(workflowsDir, 'whitesmith-reconcile.yml'),
787
- content: generateReconcileWorkflow(),
1076
+ content: generateReconcileWorkflow(config),
788
1077
  },
789
1078
  ];
790
1079
 
1080
+ if (config.reviewWorkflow) {
1081
+ files.push({
1082
+ path: path.join(workflowsDir, 'whitesmith-review.yml'),
1083
+ content: generateReviewWorkflow(config),
1084
+ });
1085
+ }
1086
+
791
1087
  if (authMode === 'auth-json') {
792
- const scriptsDir = path.join(githubDir, 'scripts');
1088
+ const scriptsDir = path.join(baseDir, 'scripts');
793
1089
  fs.mkdirSync(scriptsDir, {recursive: true});
794
1090
  files.push({
795
1091
  path: path.join(scriptsDir, 'refresh-oauth-token.mjs'),