@toast-ninja/toast-cli 0.1.0

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/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Toast CLI
2
+
3
+ Minimal CLI for Toast Review CI workflows.
4
+
5
+ See the [Review CI guide](https://github.com/toast-ninja/backend/blob/master/docs/review-ci.md)
6
+ for prerequisites and the full try-it flow.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install -g @toast-ninja/toast-cli
12
+ ```
13
+
14
+ This installs the `toast` command.
15
+
16
+ For local development from this repository:
17
+
18
+ ```bash
19
+ npm install -g ./packages/toast-cli
20
+ ```
21
+
22
+ ## Review CI Auth
23
+
24
+ ```bash
25
+ toast review-ci auth --repo toast-ninja/backend
26
+ ```
27
+
28
+ The default auth flow opens a browser, completes GitHub auth through Toast, and
29
+ stores a repo-scoped Toast token after the localhost callback returns.
30
+
31
+ If browser auth is not available, use GitHub device auth explicitly:
32
+
33
+ ```bash
34
+ toast review-ci auth --repo toast-ninja/backend --device
35
+ ```
36
+
37
+ For automation or local debugging with an existing GitHub token:
38
+
39
+ ```bash
40
+ toast review-ci auth --repo toast-ninja/backend --github-token "$GITHUB_TOKEN"
41
+ ```
42
+
43
+ That token must be a user token that can prove active membership in the repo owner org. GitHub Actions' repository `GITHUB_TOKEN` is not a user login token; use `TOAST_TOKEN` in CI.
44
+
45
+ ## Review CI
46
+
47
+ ```bash
48
+ toast review-ci wait \
49
+ --repo toast-ninja/backend \
50
+ --pr 1177 \
51
+ --head "$GITHUB_SHA" \
52
+ --timeout 20m \
53
+ --interval 10s
54
+ ```
55
+
56
+ `wait` exits `0` only when Toast returns `acknowledged` for the exact head SHA. It exits `1` for actionable non-green states and `2` for auth, network, argument, or timeout errors.
57
+
58
+ CI can also provide a token directly:
59
+
60
+ ```bash
61
+ TOAST_TOKEN="$TOAST_TOKEN" toast review-ci status \
62
+ --repo toast-ninja/backend \
63
+ --pr 1177 \
64
+ --head "$GITHUB_SHA" \
65
+ --json
66
+ ```
67
+
68
+ Use `next` to fetch the actionable Review CI payload, including unresolved
69
+ thread/comment URLs when Toast has enough context:
70
+
71
+ ```bash
72
+ toast review-ci next \
73
+ --repo toast-ninja/backend \
74
+ --pr 1177 \
75
+ --head "$GITHUB_SHA"
76
+ ```
77
+
78
+ Install agent guidance for Codex-compatible skill directories:
79
+
80
+ ```bash
81
+ toast review-ci instructions --install-codex
82
+ ```
83
+
84
+ Or print the guidance:
85
+
86
+ ```bash
87
+ toast review-ci instructions
88
+ ```
89
+
90
+ ## AI Agent Instructions
91
+
92
+ Before using `gh` to rediscover review context, run:
93
+
94
+ ```bash
95
+ toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
96
+ ```
97
+
98
+ Fix every unresolved thread/comment listed by URL. Reply with what changed,
99
+ resolve threads when requested, push the fix, then run `toast review-ci wait`
100
+ for the exact head SHA before reporting completion.
package/bin/toast.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { main } = require('../src/cli')
4
+
5
+ main({
6
+ argv: process.argv,
7
+ env: process.env,
8
+ stdout: process.stdout,
9
+ stderr: process.stderr,
10
+ }).then(
11
+ exitCode => {
12
+ process.exitCode = exitCode
13
+ },
14
+ error => {
15
+ process.stderr.write(`${error.message}\n`)
16
+ process.exitCode = 2
17
+ },
18
+ )
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@toast-ninja/toast-cli",
3
+ "version": "0.1.0",
4
+ "description": "Toast CLI for agent CI workflows",
5
+ "bin": {
6
+ "toast": "bin/toast.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "license": "ISC",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "skills",
18
+ "src/cli.js",
19
+ "src/config-store.js",
20
+ "src/http.js",
21
+ "README.md"
22
+ ]
23
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: toast-review-ci
3
+ description: Use when addressing GitHub pull request review feedback in a repo that uses Toast Review CI.
4
+ ---
5
+
6
+ # Toast Review CI
7
+
8
+ Use Toast Review CI before rediscovering review context with `gh`.
9
+
10
+ 1. Run:
11
+
12
+ ```bash
13
+ toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
14
+ ```
15
+
16
+ 2. Fix every `blocking_items` entry. Prefer the URL and body from Toast over a fresh GitHub search.
17
+ 3. Reply with what changed when a reply is requested. Include the relevant commit link if available.
18
+ 4. Resolve review threads when `suggested_reply_requirements.resolve_thread_required` is true.
19
+ 5. Push the fix, then wait on the exact new head:
20
+
21
+ ```bash
22
+ toast review-ci wait --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
23
+ ```
24
+
25
+ Only report completion after Review CI returns `acknowledged`.
package/src/cli.js ADDED
@@ -0,0 +1,603 @@
1
+ const childProcess = require('child_process')
2
+ const crypto = require('crypto')
3
+ const fs = require('fs')
4
+ const http = require('http')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const {
8
+ readConfig: defaultReadConfig,
9
+ writeConfig: defaultWriteConfig,
10
+ } = require('./config-store')
11
+ const { requestJson } = require('./http')
12
+
13
+ const DEFAULT_API_URL = 'https://api.toast.ninja'
14
+ const WAIT_STATUSES = new Set(['pending', 'degraded:reconciliation_incomplete'])
15
+ const REVIEW_CI_SKILL_PATH = path.join(
16
+ __dirname,
17
+ '..',
18
+ 'skills',
19
+ 'review-ci',
20
+ 'SKILL.md',
21
+ )
22
+ const REVIEW_CI_COMMANDS = new Set(['review-ci'])
23
+
24
+ const sleepDefault = ms =>
25
+ new Promise(resolve => {
26
+ setTimeout(resolve, ms)
27
+ })
28
+
29
+ const parseArgs = args => {
30
+ const options = {}
31
+ const positional = []
32
+
33
+ for (let index = 0; index < args.length; index += 1) {
34
+ const arg = args[index]
35
+
36
+ if (!arg.startsWith('--')) {
37
+ positional.push(arg)
38
+ } else {
39
+ const key = arg.slice(2)
40
+ const next = args[index + 1]
41
+
42
+ if (!next || next.startsWith('--')) {
43
+ options[key] = true
44
+ } else {
45
+ options[key] = next
46
+ index += 1
47
+ }
48
+ }
49
+ }
50
+
51
+ return { positional, options }
52
+ }
53
+
54
+ const parseRepo = repoFullName => {
55
+ const [owner, repo] = String(repoFullName || '').split('/')
56
+
57
+ if (!owner || !repo) throw new Error('--repo must be OWNER/REPO')
58
+
59
+ return { owner, repo, fullName: `${owner}/${repo}` }
60
+ }
61
+
62
+ const requireOption = (options, key) => {
63
+ if (!options[key]) throw new Error(`Missing --${key}`)
64
+
65
+ return options[key]
66
+ }
67
+
68
+ const normalizeApiUrl = apiUrl =>
69
+ String(apiUrl || DEFAULT_API_URL).replace(/\/+$/, '')
70
+
71
+ const getApiUrl = ({ options, env, config }) =>
72
+ normalizeApiUrl(options['api-url'] || env.TOAST_API_URL || config.apiUrl)
73
+
74
+ const getToken = ({ options, env, config }) =>
75
+ options.token || env.TOAST_TOKEN || config.token
76
+
77
+ const toMillis = value => {
78
+ if (value instanceof Date) return value.getTime()
79
+
80
+ return Number(value)
81
+ }
82
+
83
+ const parseDurationMs = value => {
84
+ const match = String(value || '').match(/^(\d+)(ms|s|m)?$/)
85
+
86
+ if (!match) throw new Error(`Invalid duration: ${value}`)
87
+
88
+ const amount = Number.parseInt(match[1], 10)
89
+ const unit = match[2] || 'ms'
90
+
91
+ if (unit === 'm') return amount * 60 * 1000
92
+ if (unit === 's') return amount * 1000
93
+
94
+ return amount
95
+ }
96
+
97
+ const parseBrowserCallbackAuth = ({ requestUrl, expectedState }) => {
98
+ const callbackUrl = new URL(requestUrl, 'http://127.0.0.1')
99
+ const state = callbackUrl.searchParams.get('state')
100
+ const error = callbackUrl.searchParams.get('error')
101
+
102
+ if (state !== expectedState) throw new Error('Invalid Review CI auth state')
103
+ if (error) throw new Error(`Review CI auth failed: ${error}`)
104
+
105
+ const auth = {
106
+ code: callbackUrl.searchParams.get('code'),
107
+ token: callbackUrl.searchParams.get('token'),
108
+ token_type: callbackUrl.searchParams.get('token_type'),
109
+ expires_in: Number(callbackUrl.searchParams.get('expires_in')),
110
+ github_login: callbackUrl.searchParams.get('github_login'),
111
+ repo: callbackUrl.searchParams.get('repo'),
112
+ }
113
+
114
+ if (auth.code) return { code: auth.code }
115
+
116
+ if (!auth.token || !auth.github_login || !auth.repo || !auth.expires_in) {
117
+ throw new Error('Review CI auth callback was missing credentials')
118
+ }
119
+
120
+ return auth
121
+ }
122
+
123
+ const createBrowserCallbackServerDefault = () =>
124
+ new Promise((resolve, reject) => {
125
+ const state = crypto.randomBytes(16).toString('hex')
126
+ let resolveCallback
127
+ let rejectCallback
128
+ const callbackPromise = new Promise((resolveAuth, rejectAuth) => {
129
+ resolveCallback = resolveAuth
130
+ rejectCallback = rejectAuth
131
+ })
132
+ const server = http.createServer((req, res) => {
133
+ if (req.method !== 'GET') {
134
+ res.statusCode = 405
135
+ res.end('Method Not Allowed')
136
+ return
137
+ }
138
+
139
+ try {
140
+ const auth = parseBrowserCallbackAuth({
141
+ requestUrl: req.url,
142
+ expectedState: state,
143
+ })
144
+
145
+ res.statusCode = 200
146
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
147
+ res.end('Review CI authentication complete. You can close this tab.')
148
+ resolveCallback(auth)
149
+ } catch (error) {
150
+ res.statusCode = 400
151
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
152
+ res.end(`Review CI authentication failed: ${error.message}`)
153
+ rejectCallback(error)
154
+ }
155
+ })
156
+
157
+ server.on('error', reject)
158
+ server.listen(0, '127.0.0.1', () => {
159
+ const address = server.address()
160
+
161
+ resolve({
162
+ state,
163
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
164
+ waitForCallback: () => callbackPromise,
165
+ close: () =>
166
+ new Promise(resolveClose => {
167
+ server.close(() => resolveClose())
168
+ }),
169
+ })
170
+ })
171
+ })
172
+
173
+ const getBrowserOpenCommand = url => {
174
+ if (process.platform === 'darwin') return { command: 'open', args: [url] }
175
+ if (process.platform === 'win32') {
176
+ return { command: 'cmd', args: ['/c', 'start', '', url] }
177
+ }
178
+
179
+ return { command: 'xdg-open', args: [url] }
180
+ }
181
+
182
+ const openBrowserDefault = url =>
183
+ new Promise((resolve, reject) => {
184
+ const { command, args } = getBrowserOpenCommand(url)
185
+ const child = childProcess.spawn(command, args, {
186
+ detached: true,
187
+ stdio: 'ignore',
188
+ })
189
+
190
+ child.once('error', reject)
191
+ child.once('spawn', () => {
192
+ child.unref()
193
+ resolve()
194
+ })
195
+ })
196
+
197
+ const buildReviewStateUrl = ({ apiUrl, repo, pullNumber, head, endpoint }) =>
198
+ `${apiUrl}/api/review-ci/repos/${encodeURIComponent(
199
+ repo.owner,
200
+ )}/${encodeURIComponent(repo.repo)}/pulls/${encodeURIComponent(
201
+ pullNumber,
202
+ )}/${endpoint}?head=${encodeURIComponent(head)}`
203
+
204
+ const requestReviewState = async ({
205
+ options,
206
+ env,
207
+ config,
208
+ request,
209
+ endpoint = 'status',
210
+ }) => {
211
+ const repo = parseRepo(requireOption(options, 'repo'))
212
+ const pullNumber = requireOption(options, 'pr')
213
+ const head = requireOption(options, 'head')
214
+ const apiUrl = getApiUrl({ options, env, config })
215
+ const token = getToken({ options, env, config })
216
+
217
+ if (!token)
218
+ throw new Error(
219
+ 'Missing Toast token. Run `toast review-ci auth` or set TOAST_TOKEN.',
220
+ )
221
+
222
+ const response = await request({
223
+ method: 'GET',
224
+ url: buildReviewStateUrl({ apiUrl, repo, pullNumber, head, endpoint }),
225
+ headers: {
226
+ Authorization: `Bearer ${token}`,
227
+ },
228
+ })
229
+
230
+ if (response.statusCode >= 400) {
231
+ throw new Error(`Review CI request failed with HTTP ${response.statusCode}`)
232
+ }
233
+
234
+ return response.body
235
+ }
236
+
237
+ const getReviewStateArgs = context => context.commandArgs || context.argv.slice(4)
238
+
239
+ const writeReviewState = ({ state, stdout, asJson, includeBlockingItems }) => {
240
+ if (asJson) {
241
+ stdout.write(`${JSON.stringify(state, null, 2)}\n`)
242
+ return
243
+ }
244
+
245
+ stdout.write(
246
+ `Review CI ${state.status} head=${state.head || '<unknown>'} next=${
247
+ state.next_action
248
+ }\n`,
249
+ )
250
+
251
+ if (!includeBlockingItems || !state.blocking_items?.length) return
252
+
253
+ stdout.write('Blocking items:\n')
254
+ state.blocking_items.forEach((item, index) => {
255
+ const location = [item.path, item.line].filter(Boolean).join(':')
256
+ const reviewer = item.reviewer || '<unknown>'
257
+
258
+ stdout.write(
259
+ `${index + 1}. ${item.kind} ${item.url || '<no URL>'} reviewer=${reviewer}`,
260
+ )
261
+ if (location) stdout.write(` location=${location}`)
262
+ stdout.write('\n')
263
+ })
264
+ }
265
+
266
+ const handleStatus = async context => {
267
+ const config = context.readConfig()
268
+ const { options } = parseArgs(getReviewStateArgs(context))
269
+ const state = await requestReviewState({ ...context, config, options })
270
+
271
+ writeReviewState({ state, stdout: context.stdout, asJson: options.json })
272
+
273
+ return state.status === 'acknowledged' ? 0 : 1
274
+ }
275
+
276
+ const handleNext = async context => {
277
+ const config = context.readConfig()
278
+ const { options } = parseArgs(getReviewStateArgs(context))
279
+ const state = await requestReviewState({
280
+ ...context,
281
+ config,
282
+ options,
283
+ endpoint: 'next',
284
+ })
285
+
286
+ writeReviewState({
287
+ state,
288
+ stdout: context.stdout,
289
+ asJson: options.json,
290
+ includeBlockingItems: true,
291
+ })
292
+
293
+ return state.status === 'acknowledged' ? 0 : 1
294
+ }
295
+
296
+ const handleWait = async context => {
297
+ const config = context.readConfig()
298
+ const { options } = parseArgs(getReviewStateArgs(context))
299
+ const timeoutMs = parseDurationMs(options.timeout || '20m')
300
+ const intervalMs = parseDurationMs(options.interval || '10s')
301
+ const startedAt = toMillis(context.now())
302
+ let elapsedMs = 0
303
+
304
+ while (elapsedMs <= timeoutMs) {
305
+ // eslint-disable-next-line no-await-in-loop
306
+ const state = await requestReviewState({ ...context, config, options })
307
+
308
+ if (state.status === 'acknowledged') {
309
+ writeReviewState({ state, stdout: context.stdout, asJson: options.json })
310
+ return 0
311
+ }
312
+
313
+ if (!WAIT_STATUSES.has(state.status)) {
314
+ writeReviewState({ state, stdout: context.stdout, asJson: options.json })
315
+ return 1
316
+ }
317
+
318
+ // eslint-disable-next-line no-await-in-loop
319
+ await context.sleep(intervalMs)
320
+ elapsedMs += intervalMs
321
+
322
+ if (toMillis(context.now()) - startedAt > timeoutMs) {
323
+ break
324
+ }
325
+ }
326
+
327
+ context.stderr.write('Timed out waiting for Review CI acknowledgement\n')
328
+ return 2
329
+ }
330
+
331
+ const completeLogin = ({ context, auth, apiUrl }) => {
332
+ const existingConfig = context.readConfig()
333
+ const expiresAt = new Date(
334
+ toMillis(context.now()) + auth.expires_in * 1000,
335
+ ).toISOString()
336
+
337
+ context.writeConfig({
338
+ ...existingConfig,
339
+ apiUrl,
340
+ repo: auth.repo,
341
+ token: auth.token,
342
+ githubLogin: auth.github_login,
343
+ expiresAt,
344
+ })
345
+ context.stdout.write(`Authenticated ${auth.github_login} for ${auth.repo}\n`)
346
+ context.stdout.write(
347
+ 'Agent setup: run `toast review-ci instructions --install-codex` to install Review CI guidance.\n',
348
+ )
349
+ }
350
+
351
+ const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
352
+ const githubToken =
353
+ options['github-token'] === true
354
+ ? context.env.GITHUB_TOKEN
355
+ : options['github-token']
356
+
357
+ if (!githubToken) throw new Error('Missing --github-token')
358
+
359
+ const response = await context.request({
360
+ method: 'POST',
361
+ url: `${apiUrl}/api/review-ci/auth/github`,
362
+ body: {
363
+ repo: repo.fullName,
364
+ github_access_token: githubToken,
365
+ },
366
+ })
367
+
368
+ if (response.statusCode >= 400) {
369
+ throw new Error(`Login failed with HTTP ${response.statusCode}`)
370
+ }
371
+
372
+ return response.body
373
+ }
374
+
375
+ const runDeviceLogin = async ({ context, apiUrl, repo }) => {
376
+ const startResponse = await context.request({
377
+ method: 'POST',
378
+ url: `${apiUrl}/api/review-ci/auth/github/device/start`,
379
+ body: {
380
+ repo: repo.fullName,
381
+ },
382
+ })
383
+
384
+ if (startResponse.statusCode >= 400) {
385
+ throw new Error(`Device login failed with HTTP ${startResponse.statusCode}`)
386
+ }
387
+
388
+ const device = startResponse.body
389
+ const intervalMs = Number(device.interval || 5) * 1000
390
+ let elapsedMs = 0
391
+
392
+ context.stdout.write(
393
+ `Open ${device.verification_uri} and enter code ${device.user_code}\n`,
394
+ )
395
+
396
+ while (elapsedMs <= Number(device.expires_in || 900) * 1000) {
397
+ // eslint-disable-next-line no-await-in-loop
398
+ const completeResponse = await context.request({
399
+ method: 'POST',
400
+ url: `${apiUrl}/api/review-ci/auth/github/device/complete`,
401
+ body: {
402
+ repo: repo.fullName,
403
+ device_code: device.device_code,
404
+ },
405
+ })
406
+
407
+ if (completeResponse.statusCode === 200) return completeResponse.body
408
+
409
+ if (completeResponse.body?.error !== 'authorization_pending') {
410
+ throw new Error(`Device login failed with HTTP ${completeResponse.statusCode}`)
411
+ }
412
+
413
+ // eslint-disable-next-line no-await-in-loop
414
+ await context.sleep(intervalMs)
415
+ elapsedMs += intervalMs
416
+ }
417
+
418
+ throw new Error('Device login timed out')
419
+ }
420
+
421
+ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
422
+ const callback = await context.createBrowserCallbackServer()
423
+
424
+ try {
425
+ const startResponse = await context.request({
426
+ method: 'POST',
427
+ url: `${apiUrl}/api/review-ci/auth/github/browser/start`,
428
+ body: {
429
+ repo: repo.fullName,
430
+ owner: repo.owner,
431
+ repository: repo.repo,
432
+ redirect_uri: callback.redirectUri,
433
+ state: callback.state,
434
+ },
435
+ })
436
+
437
+ if (startResponse.statusCode >= 400) {
438
+ throw new Error(`Browser login failed with HTTP ${startResponse.statusCode}`)
439
+ }
440
+
441
+ const authorizationUrl =
442
+ startResponse.body?.authorization_url || startResponse.body?.browser_url
443
+
444
+ if (!authorizationUrl) {
445
+ throw new Error('Browser login did not return an authorization URL')
446
+ }
447
+
448
+ context.stdout.write('Opening browser for Review CI authentication...\n')
449
+ await context.openBrowser(authorizationUrl)
450
+
451
+ const callbackAuth = await Promise.race([
452
+ callback.waitForCallback(),
453
+ context
454
+ .sleep(Number(startResponse.body?.expires_in || 600) * 1000)
455
+ .then(() => {
456
+ throw new Error('Browser login timed out')
457
+ }),
458
+ ])
459
+
460
+ if (!callbackAuth.code) return callbackAuth
461
+
462
+ const completeResponse = await context.request({
463
+ method: 'POST',
464
+ url: `${apiUrl}/api/review-ci/auth/github/browser/complete`,
465
+ body: {
466
+ code: callbackAuth.code,
467
+ },
468
+ })
469
+
470
+ if (completeResponse.statusCode >= 400) {
471
+ throw new Error(
472
+ `Browser login failed with HTTP ${completeResponse.statusCode}`,
473
+ )
474
+ }
475
+
476
+ return completeResponse.body
477
+ } finally {
478
+ await callback.close()
479
+ }
480
+ }
481
+
482
+ const handleAuth = async context => {
483
+ const config = context.readConfig()
484
+ const { options } = parseArgs(context.commandArgs || context.argv.slice(3))
485
+ const repo = parseRepo(requireOption(options, 'repo'))
486
+ const apiUrl = getApiUrl({ options, env: context.env, config })
487
+ let auth
488
+
489
+ if (options['github-token']) {
490
+ auth = await exchangeGithubToken({ context, options, apiUrl, repo })
491
+ } else if (options.device) {
492
+ auth = await runDeviceLogin({ context, apiUrl, repo })
493
+ } else {
494
+ auth = await runBrowserLogin({ context, apiUrl, repo })
495
+ }
496
+
497
+ completeLogin({ context, auth, apiUrl })
498
+
499
+ return 0
500
+ }
501
+
502
+ const readReviewCiSkill = () => fs.readFileSync(REVIEW_CI_SKILL_PATH, 'utf8')
503
+
504
+ const getCodexSkillPath = env =>
505
+ path.join(
506
+ env.CODEX_HOME || path.join(os.homedir(), '.codex'),
507
+ 'skills',
508
+ 'toast-review-ci',
509
+ 'SKILL.md',
510
+ )
511
+
512
+ const installCodexSkill = ({ env, stdout }) => {
513
+ const skillPath = getCodexSkillPath(env)
514
+
515
+ fs.mkdirSync(path.dirname(skillPath), { recursive: true, mode: 0o700 })
516
+ fs.writeFileSync(skillPath, readReviewCiSkill(), { mode: 0o600 })
517
+ stdout.write(`Installed Review CI agent instructions to ${skillPath}\n`)
518
+ }
519
+
520
+ const handleInstructions = async context => {
521
+ const { options } = parseArgs(context.commandArgs || [])
522
+
523
+ if (options['install-codex']) {
524
+ installCodexSkill({ env: context.env, stdout: context.stdout })
525
+ return 0
526
+ }
527
+
528
+ context.stdout.write(readReviewCiSkill())
529
+ return 0
530
+ }
531
+
532
+ const printHelp = stdout => {
533
+ stdout.write(`Usage:
534
+ toast review-ci auth --repo OWNER/REPO [--device|--github-token TOKEN]
535
+ toast review-ci status --repo OWNER/REPO --pr NUMBER --head SHA
536
+ toast review-ci next --repo OWNER/REPO --pr NUMBER --head SHA
537
+ toast review-ci wait --repo OWNER/REPO --pr NUMBER --head SHA
538
+ toast review-ci instructions [--install-codex]
539
+ `)
540
+ }
541
+
542
+ const handleReviewCiCommand = async (context, command) => {
543
+ if (command === 'auth') return handleAuth(context)
544
+ if (command === 'status') return handleStatus(context)
545
+ if (command === 'next') return handleNext(context)
546
+ if (command === 'wait') return handleWait(context)
547
+ if (command === 'instructions') return handleInstructions(context)
548
+
549
+ printHelp(context.stdout)
550
+ return command ? 2 : 0
551
+ }
552
+
553
+ const main = async ({
554
+ argv,
555
+ env = process.env,
556
+ request = requestJson,
557
+ readConfig = defaultReadConfig,
558
+ writeConfig = defaultWriteConfig,
559
+ sleep = sleepDefault,
560
+ now = () => new Date(),
561
+ createBrowserCallbackServer = createBrowserCallbackServerDefault,
562
+ openBrowser = openBrowserDefault,
563
+ stdout = process.stdout,
564
+ stderr = process.stderr,
565
+ }) => {
566
+ const context = {
567
+ argv,
568
+ env,
569
+ request,
570
+ readConfig,
571
+ writeConfig,
572
+ sleep,
573
+ now,
574
+ createBrowserCallbackServer,
575
+ openBrowser,
576
+ stdout,
577
+ stderr,
578
+ }
579
+
580
+ try {
581
+ if (argv[2] === 'login') {
582
+ return await handleAuth({ ...context, commandArgs: argv.slice(3) })
583
+ }
584
+
585
+ if (REVIEW_CI_COMMANDS.has(argv[2])) {
586
+ return await handleReviewCiCommand(
587
+ { ...context, commandArgs: argv.slice(4) },
588
+ argv[3],
589
+ )
590
+ }
591
+
592
+ printHelp(stdout)
593
+ return argv[2] ? 2 : 0
594
+ } catch (error) {
595
+ stderr.write(`${error.message}\n`)
596
+ return 2
597
+ }
598
+ }
599
+
600
+ module.exports = {
601
+ main,
602
+ parseDurationMs,
603
+ }
@@ -0,0 +1,38 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+
5
+ const getConfigPath = (env = process.env) => {
6
+ if (env.TOAST_CONFIG_PATH) return env.TOAST_CONFIG_PATH
7
+
8
+ const configHome =
9
+ env.TOAST_CONFIG_HOME ||
10
+ env.XDG_CONFIG_HOME ||
11
+ path.join(os.homedir(), '.config')
12
+
13
+ return path.join(configHome, 'toast', 'config.json')
14
+ }
15
+
16
+ const readConfig = (env = process.env) => {
17
+ const configPath = getConfigPath(env)
18
+
19
+ if (!fs.existsSync(configPath)) return {}
20
+
21
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'))
22
+ }
23
+
24
+ const writeConfig = (config, env = process.env) => {
25
+ const configPath = getConfigPath(env)
26
+
27
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 })
28
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, {
29
+ mode: 0o600,
30
+ })
31
+ fs.chmodSync(configPath, 0o600)
32
+ }
33
+
34
+ module.exports = {
35
+ getConfigPath,
36
+ readConfig,
37
+ writeConfig,
38
+ }
package/src/http.js ADDED
@@ -0,0 +1,74 @@
1
+ const http = require('http')
2
+ const https = require('https')
3
+
4
+ const DEFAULT_TIMEOUT_MS = 30000
5
+
6
+ const requestJson = ({
7
+ method = 'GET',
8
+ url,
9
+ headers = {},
10
+ body,
11
+ timeoutMs = DEFAULT_TIMEOUT_MS,
12
+ }) =>
13
+ new Promise((resolve, reject) => {
14
+ const target = new URL(url)
15
+ const payload = body === undefined ? undefined : JSON.stringify(body)
16
+ const client = target.protocol === 'http:' ? http : https
17
+ const request = client.request(
18
+ target,
19
+ {
20
+ method,
21
+ headers: {
22
+ Accept: 'application/json',
23
+ 'User-Agent': '@toast-ninja/toast-cli',
24
+ ...(payload
25
+ ? {
26
+ 'Content-Type': 'application/json',
27
+ 'Content-Length': Buffer.byteLength(payload),
28
+ }
29
+ : {}),
30
+ ...headers,
31
+ },
32
+ },
33
+ response => {
34
+ let responseBody = ''
35
+
36
+ response.setEncoding('utf8')
37
+ response.on('data', chunk => {
38
+ responseBody += chunk
39
+ })
40
+ response.on('end', () => {
41
+ let parsedBody = null
42
+
43
+ if (responseBody) {
44
+ try {
45
+ parsedBody = JSON.parse(responseBody)
46
+ } catch (e) {
47
+ parsedBody = responseBody
48
+ }
49
+ }
50
+
51
+ resolve({
52
+ statusCode: response.statusCode,
53
+ body: parsedBody,
54
+ headers: response.headers,
55
+ })
56
+ })
57
+ },
58
+ )
59
+
60
+ request.on('error', reject)
61
+ request.setTimeout(timeoutMs, () => {
62
+ const error = new Error(`Request timed out after ${timeoutMs}ms`)
63
+ error.code = 'ETIMEDOUT'
64
+ request.destroy(error)
65
+ })
66
+
67
+ if (payload) request.write(payload)
68
+ request.end()
69
+ })
70
+
71
+ module.exports = {
72
+ DEFAULT_TIMEOUT_MS,
73
+ requestJson,
74
+ }