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.
- package/dist/auto-work.d.ts +11 -0
- package/dist/auto-work.d.ts.map +1 -0
- package/dist/auto-work.js +22 -0
- package/dist/auto-work.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +99 -0
- package/dist/cli.js.map +1 -1
- package/dist/comment.d.ts +29 -0
- package/dist/comment.d.ts.map +1 -0
- package/dist/comment.js +390 -0
- package/dist/comment.js.map +1 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +57 -14
- package/dist/git.js.map +1 -1
- package/dist/harnesses/pi.d.ts +2 -0
- package/dist/harnesses/pi.d.ts.map +1 -1
- package/dist/harnesses/pi.js +92 -6
- package/dist/harnesses/pi.js.map +1 -1
- package/dist/install-ci.d.ts +7 -0
- package/dist/install-ci.d.ts.map +1 -0
- package/dist/install-ci.js +760 -0
- package/dist/install-ci.js.map +1 -0
- package/dist/orchestrator.d.ts +24 -4
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +252 -67
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +1 -0
- package/dist/prompts.js.map +1 -1
- package/dist/providers/github-ci.d.ts +16 -0
- package/dist/providers/github-ci.d.ts.map +1 -0
- package/dist/providers/github-ci.js +733 -0
- package/dist/providers/github-ci.js.map +1 -0
- package/dist/providers/github.d.ts +21 -0
- package/dist/providers/github.d.ts.map +1 -1
- package/dist/providers/github.js +88 -3
- package/dist/providers/github.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/issue-provider.d.ts +26 -0
- package/dist/providers/issue-provider.d.ts.map +1 -1
- package/dist/task-manager.d.ts +4 -0
- package/dist/task-manager.d.ts.map +1 -1
- package/dist/task-manager.js +6 -0
- package/dist/task-manager.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/src/auto-work.ts +26 -0
- package/src/cli.ts +114 -0
- package/src/comment.ts +531 -0
- package/src/git.ts +58 -12
- package/src/harnesses/pi.ts +108 -6
- package/src/orchestrator.ts +287 -76
- package/src/prompts.ts +1 -0
- package/src/providers/github-ci.ts +840 -0
- package/src/providers/github.ts +118 -5
- package/src/providers/index.ts +1 -0
- package/src/providers/issue-provider.ts +25 -1
- package/src/task-manager.ts +7 -0
- 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
|