@toast-ninja/toast-cli 0.1.3 → 0.1.6

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 CHANGED
@@ -36,6 +36,15 @@ scope:
36
36
  toast auth --org toast-ninja
37
37
  ```
38
38
 
39
+ You can authenticate multiple orgs on the same machine. The CLI stores tokens by
40
+ org and picks the token that matches `--repo`:
41
+
42
+ ```bash
43
+ toast auth --org toast-ninja
44
+ toast auth --org another-org
45
+ toast review-ci next --repo another-org/backend --pr 1177 --head "$GITHUB_SHA"
46
+ ```
47
+
39
48
  If browser auth is not available, use GitHub device auth explicitly:
40
49
 
41
50
  ```bash
@@ -52,7 +61,7 @@ That token must be a user token that can prove active membership in the org.
52
61
  GitHub Actions' repository `GITHUB_TOKEN` is not a user login token; use
53
62
  `TOAST_TOKEN` in CI.
54
63
 
55
- The older `toast review-ci auth --repo OWNER/REPO` command still works as a
64
+ The older `toast review-ci auth --repo ORG/REPO` command still works as a
56
65
  compatibility alias.
57
66
 
58
67
  ## Review CI
@@ -65,9 +74,13 @@ toast review-ci wait \
65
74
  --timeout 20m
66
75
  ```
67
76
 
68
- `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.
77
+ `wait` exits `0` only when Toast returns `ready` for the exact head SHA. It exits `1` for actionable non-green states and `2` for auth, network, argument, or timeout errors.
69
78
  It waits on Toast's server-side Review CI events through bounded requests, so a
70
- local polling interval is not needed.
79
+ local polling interval is not needed. If a wait stays pending, the CLI prints
80
+ the current `waiting_for` reason to stderr; when a bounded wait wakes from a
81
+ webhook or timeout, Toast reconciles exact GitHub PR state when needed.
82
+ Reconciled responses include concrete GitHub check names in
83
+ `github_checks` / `waiting_for.github_checks` when CI is pending or failing.
71
84
 
72
85
  CI can also provide a token directly:
73
86
 
@@ -89,12 +102,17 @@ toast review-ci next \
89
102
  --head "$GITHUB_SHA"
90
103
  ```
91
104
 
92
- Install agent guidance for Codex-compatible skill directories:
105
+ Install agent guidance from the project checkout:
93
106
 
94
107
  ```bash
95
- toast review-ci instructions --install-codex
108
+ toast review-ci instructions --install
96
109
  ```
97
110
 
111
+ This installs the Codex skill and writes a bounded Toast Review CI section to
112
+ `CLAUDE.md` and `AGENTS.md`. The `--install-agent` flag is an alias for
113
+ `--install`; the older `--install-codex` flag still installs only the Codex
114
+ skill.
115
+
98
116
  Or print the guidance:
99
117
 
100
118
  ```bash
@@ -103,12 +121,22 @@ toast review-ci instructions
103
121
 
104
122
  ## AI Agent Instructions
105
123
 
106
- Before using `gh` to rediscover review context, run:
124
+ Use Review CI as the source of truth for PR readiness. Before using `gh` to
125
+ rediscover review context, run:
126
+
127
+ ```bash
128
+ toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
129
+ ```
130
+
131
+ Keep iterating until the response status is `ready`. For `needs_fix`, fix every
132
+ `blocking_items` entry, reply with what changed, resolve threads when requested,
133
+ push, refresh the exact head SHA, then run:
107
134
 
108
135
  ```bash
109
- toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
136
+ toast review-ci wait --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
110
137
  ```
111
138
 
112
- Fix every unresolved thread/comment listed by URL. Reply with what changed,
113
- resolve threads when requested, push the fix, then run `toast review-ci wait`
114
- for the exact head SHA before reporting completion.
139
+ If `wait` returns `ready`, report completion. If it returns `needs_fix`, use the
140
+ returned `blocking_items` and continue the fix loop instead of claiming the PR
141
+ is done. For `pending` or `degraded:reconciliation_incomplete`, run `wait` for
142
+ the same head SHA and use `waiting_for` to see what Toast is waiting on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toast-ninja/toast-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Toast CLI for agent CI workflows",
5
5
  "bin": {
6
6
  "toast": "bin/toast.js"
@@ -5,21 +5,42 @@ description: Use when addressing GitHub pull request review feedback in a repo t
5
5
 
6
6
  # Toast Review CI
7
7
 
8
- Use Toast Review CI before rediscovering review context with `gh`.
8
+ Use Toast Review CI as the source of truth for PR review readiness before
9
+ rediscovering review context with `gh`.
9
10
 
10
- 1. Run:
11
+ Goal: keep iterating until Review CI returns `status: "ready"` for the exact
12
+ current head SHA. Only report completion after Review CI returns `ready`.
13
+
14
+ ## Review Loop
15
+
16
+ 1. Get the current PR head SHA, then run:
11
17
 
12
18
  ```bash
13
- toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
19
+ toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
14
20
  ```
15
21
 
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:
22
+ 2. Follow the returned status:
23
+ - `ready`: the PR is ready. Report completion.
24
+ - `needs_fix`: fix every `blocking_items` entry. Prefer the URL and body
25
+ from Toast over a fresh GitHub search.
26
+ - `pending` or `degraded:reconciliation_incomplete`: run
27
+ `toast review-ci wait` for the same head SHA, using `waiting_for` to see
28
+ what Toast is waiting on, then continue from step 6.
29
+ - `head_changed`: refresh the head SHA and restart the loop.
30
+ - `draft`, `github_blocked`, `policy_blocked:unknown_actor`, or
31
+ `degraded:timeout`: fix the blocker when possible; otherwise report the
32
+ blocker instead of claiming the PR is ready.
33
+ 3. When fixing `blocking_items`, reply with what changed when a reply is
34
+ requested. Include the relevant commit link if available.
35
+ 4. Resolve review threads when
36
+ `suggested_reply_requirements.resolve_thread_required` is true.
37
+ 5. Push the fix, refresh the exact new head SHA, then wait:
20
38
 
21
39
  ```bash
22
- toast review-ci wait --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
40
+ toast review-ci wait --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
23
41
  ```
24
42
 
25
- Only report completion after Review CI returns `acknowledged`.
43
+ 6. If `wait` returns `ready`, report completion. If it returns `needs_fix`, use
44
+ the returned `blocking_items` and continue the fix loop. If it returns
45
+ `head_changed` or another non-ready status, continue from the matching step
46
+ above.
package/src/cli.js CHANGED
@@ -13,11 +13,14 @@ const { requestJson } = require('./http')
13
13
  const DEFAULT_API_URL = 'https://api.toast.ninja'
14
14
  const DEFAULT_WAIT_REQUEST_SECONDS = 25
15
15
  const WAIT_REQUEST_GRACE_MS = 5000
16
+ const WAIT_DIAGNOSTIC_INTERVAL_MS = 60 * 1000
17
+ const WAIT_STALLED_AFTER_MS = 5 * 60 * 1000
16
18
  const WAIT_STATUSES = new Set([
17
19
  'pending',
18
20
  'degraded:reconciliation_incomplete',
19
21
  'head_changed',
20
22
  ])
23
+ const READY_STATUSES = new Set(['ready', 'acknowledged'])
21
24
  const REVIEW_CI_SKILL_PATH = path.join(
22
25
  __dirname,
23
26
  '..',
@@ -25,6 +28,8 @@ const REVIEW_CI_SKILL_PATH = path.join(
25
28
  'review-ci',
26
29
  'SKILL.md',
27
30
  )
31
+ const REVIEW_CI_INSTRUCTIONS_START = '<!-- toast-review-ci:start -->'
32
+ const REVIEW_CI_INSTRUCTIONS_END = '<!-- toast-review-ci:end -->'
28
33
  const REVIEW_CI_COMMANDS = new Set(['review-ci'])
29
34
  const AUTH_COMMANDS = new Set(['auth', 'login'])
30
35
 
@@ -65,7 +70,7 @@ const parseRepo = repoFullName => {
65
70
  const [owner, repo] = parts
66
71
 
67
72
  if (parts.length !== 2 || !owner || !repo) {
68
- throw new Error('--repo must be OWNER/REPO')
73
+ throw new Error('--repo must be ORG/REPO')
69
74
  }
70
75
 
71
76
  return { owner, repo, fullName: `${owner}/${repo}` }
@@ -77,7 +82,7 @@ const parseOrg = org => {
77
82
  const normalizedOrg = String(org || '').trim()
78
83
 
79
84
  if (!normalizedOrg || normalizedOrg.includes('/')) {
80
- throw new Error('--org must be a GitHub organization or owner name')
85
+ throw new Error('--org must be a GitHub organization name')
81
86
  }
82
87
 
83
88
  return normalizedOrg
@@ -85,14 +90,14 @@ const parseOrg = org => {
85
90
 
86
91
  const parseGithubRemoteUrl = remoteUrl => {
87
92
  const value = String(remoteUrl || '').trim()
88
- const sshMatch = value.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/)
93
+ const sshMatch = value.match(/^[^@/:]+@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/)
89
94
 
90
95
  if (sshMatch) return parseRepo(`${sshMatch[1]}/${sshMatch[2]}`)
91
96
 
92
97
  try {
93
98
  const url = new URL(value)
94
99
 
95
- if (url.hostname !== 'github.com') return null
100
+ if (!['git:', 'http:', 'https:', 'ssh:'].includes(url.protocol)) return null
96
101
 
97
102
  const parts = url.pathname
98
103
  .replace(/^\/+/, '')
@@ -172,8 +177,44 @@ const normalizeApiUrl = apiUrl =>
172
177
  const getApiUrl = ({ options, env, config }) =>
173
178
  normalizeApiUrl(options['api-url'] || env.TOAST_API_URL || config.apiUrl)
174
179
 
175
- const getToken = ({ options, env, config }) =>
176
- options.token || env.TOAST_TOKEN || config.token
180
+ const getScopedTokenEntry = ({ config, repo }) => {
181
+ if (!repo) return null
182
+
183
+ const repoEntry = config.tokensByRepo?.[repo.fullName]
184
+ if (repoEntry?.token) return repoEntry
185
+
186
+ const orgEntry = config.tokensByOrg?.[repo.owner]
187
+ if (orgEntry?.token) return orgEntry
188
+
189
+ return null
190
+ }
191
+
192
+ const legacyConfigMatchesRepo = ({ config, repo }) => {
193
+ if (!config.token) return false
194
+ if (!config.org && !config.repo) return false
195
+
196
+ if (config.repo) {
197
+ try {
198
+ return parseRepo(config.repo).fullName === repo.fullName
199
+ } catch (e) {
200
+ return false
201
+ }
202
+ }
203
+
204
+ return config.org === repo.owner
205
+ }
206
+
207
+ const getToken = ({ options, env, config, repo }) => {
208
+ if (options.token) return options.token
209
+ if (env.TOAST_TOKEN) return env.TOAST_TOKEN
210
+
211
+ const scopedEntry = getScopedTokenEntry({ config, repo })
212
+ if (scopedEntry) return scopedEntry.token
213
+
214
+ if (legacyConfigMatchesRepo({ config, repo })) return config.token
215
+
216
+ return null
217
+ }
177
218
 
178
219
  const toMillis = value => {
179
220
  if (value instanceof Date) return value.getTime()
@@ -195,6 +236,16 @@ const parseDurationMs = value => {
195
236
  return amount
196
237
  }
197
238
 
239
+ const formatDurationMs = ms => {
240
+ const seconds = Math.max(0, Math.round(Number(ms) / 1000))
241
+ if (seconds < 60) return `${seconds}s`
242
+
243
+ const minutes = Math.floor(seconds / 60)
244
+ const remainingSeconds = seconds % 60
245
+
246
+ return remainingSeconds ? `${minutes}m${remainingSeconds}s` : `${minutes}m`
247
+ }
248
+
198
249
  const parseBrowserCallbackAuth = ({ requestUrl, expectedState }) => {
199
250
  const callbackUrl = new URL(requestUrl, 'http://127.0.0.1')
200
251
  const state = callbackUrl.searchParams.get('state')
@@ -334,10 +385,12 @@ const requestReviewState = async ({
334
385
  const pullNumber = requireOption(options, 'pr')
335
386
  const head = requireOption(options, 'head')
336
387
  const apiUrl = getApiUrl({ options, env, config })
337
- const token = getToken({ options, env, config })
388
+ const token = getToken({ options, env, config, repo })
338
389
 
339
390
  if (!token)
340
- throw new Error('Missing Toast token. Run `toast auth` or set TOAST_TOKEN.')
391
+ throw new Error(
392
+ `Missing Toast token for ${repo.owner}. Run \`toast auth --org ${repo.owner}\` or set TOAST_TOKEN.`,
393
+ )
341
394
 
342
395
  const requestOptions = {
343
396
  method: 'GET',
@@ -396,6 +449,75 @@ const writeReviewState = ({ state, stdout, asJson, includeBlockingItems }) => {
396
449
  })
397
450
  }
398
451
 
452
+ const toList = values => (values || []).filter(Boolean).join(', ')
453
+
454
+ const getWaitReasonParts = state => {
455
+ const waitingFor = state.waiting_for || {}
456
+ const reasons = []
457
+
458
+ if (waitingFor.pending_actors?.length) {
459
+ reasons.push(`open review request for ${toList(waitingFor.pending_actors)}`)
460
+ }
461
+ if (waitingFor.blocking_actors?.length) {
462
+ reasons.push(
463
+ `missing current-head review signal from ${toList(
464
+ waitingFor.blocking_actors,
465
+ )}`,
466
+ )
467
+ }
468
+ if (waitingFor.github_mergeability) {
469
+ reasons.push(`GitHub mergeability is ${waitingFor.github_mergeability}`)
470
+ }
471
+ if (waitingFor.github_checks?.length) {
472
+ reasons.push(`GitHub checks: ${toList(waitingFor.github_checks)}`)
473
+ }
474
+ if (state.status === 'head_changed') {
475
+ reasons.push(
476
+ `Toast has head ${state.head || '<unknown>'}; requested ${
477
+ state.requested_head || '<unknown>'
478
+ }`,
479
+ )
480
+ }
481
+ if (state.status === 'degraded:reconciliation_incomplete') {
482
+ reasons.push('exact GitHub reconciliation is incomplete; retrying')
483
+ }
484
+
485
+ return reasons
486
+ }
487
+
488
+ const getWaitStateKey = state =>
489
+ JSON.stringify({
490
+ status: state.status,
491
+ head: state.head,
492
+ next_action: state.next_action,
493
+ counts: state.counts,
494
+ waiting_for: state.waiting_for,
495
+ github_mergeability: state.github_mergeability,
496
+ })
497
+
498
+ const writeWaitDiagnostic = ({ state, stderr, elapsedMs, unchangedMs }) => {
499
+ const reasons = getWaitReasonParts(state)
500
+ const reasonText = reasons.length
501
+ ? reasons.join('; ')
502
+ : 'Toast did not return waiting_for details; run `toast review-ci next --json` to inspect the current state'
503
+
504
+ stderr.write(
505
+ `Review CI still ${state.status} after ${formatDurationMs(
506
+ elapsedMs,
507
+ )}: ${reasonText}.\n`,
508
+ )
509
+
510
+ if (unchangedMs >= WAIT_STALLED_AFTER_MS) {
511
+ stderr.write(
512
+ `Review CI state has not changed for ${formatDurationMs(
513
+ unchangedMs,
514
+ )}; inspect GitHub review requests/checks, re-request the stuck reviewer, or report this blocker.\n`,
515
+ )
516
+ }
517
+ }
518
+
519
+ const isReadyState = state => READY_STATUSES.has(state.status)
520
+
399
521
  const handleStatus = async context => {
400
522
  const config = context.readConfig()
401
523
  const { options } = parseArgs(getReviewStateArgs(context))
@@ -403,7 +525,7 @@ const handleStatus = async context => {
403
525
 
404
526
  writeReviewState({ state, stdout: context.stdout, asJson: options.json })
405
527
 
406
- return state.status === 'acknowledged' ? 0 : 1
528
+ return isReadyState(state) ? 0 : 1
407
529
  }
408
530
 
409
531
  const handleNext = async context => {
@@ -423,7 +545,7 @@ const handleNext = async context => {
423
545
  includeBlockingItems: true,
424
546
  })
425
547
 
426
- return state.status === 'acknowledged' ? 0 : 1
548
+ return isReadyState(state) ? 0 : 1
427
549
  }
428
550
 
429
551
  const handleWait = async context => {
@@ -432,6 +554,9 @@ const handleWait = async context => {
432
554
  const timeoutMs = parseDurationMs(options.timeout || '20m')
433
555
  if (options.interval) parseDurationMs(options.interval)
434
556
  const startedAt = toMillis(context.now())
557
+ let lastStateKey = null
558
+ let lastStateChangedAt = startedAt
559
+ let lastDiagnosticAt = null
435
560
 
436
561
  while (true) {
437
562
  const elapsedMs = toMillis(context.now()) - startedAt
@@ -453,7 +578,7 @@ const handleWait = async context => {
453
578
  timeoutMs: waitTimeoutSeconds * 1000 + WAIT_REQUEST_GRACE_MS,
454
579
  })
455
580
 
456
- if (state.status === 'acknowledged') {
581
+ if (isReadyState(state)) {
457
582
  writeReviewState({ state, stdout: context.stdout, asJson: options.json })
458
583
  return 0
459
584
  }
@@ -462,34 +587,106 @@ const handleWait = async context => {
462
587
  writeReviewState({ state, stdout: context.stdout, asJson: options.json })
463
588
  return 1
464
589
  }
590
+
591
+ const observedAt = toMillis(context.now())
592
+ const stateKey = getWaitStateKey(state)
593
+ if (stateKey !== lastStateKey) {
594
+ lastStateKey = stateKey
595
+ lastStateChangedAt = observedAt
596
+ }
597
+ if (
598
+ lastDiagnosticAt === null ||
599
+ observedAt - lastDiagnosticAt >= WAIT_DIAGNOSTIC_INTERVAL_MS
600
+ ) {
601
+ writeWaitDiagnostic({
602
+ state,
603
+ stderr: context.stderr,
604
+ elapsedMs: observedAt - startedAt,
605
+ unchangedMs: observedAt - lastStateChangedAt,
606
+ })
607
+ lastDiagnosticAt = observedAt
608
+ }
465
609
  }
466
610
 
467
- context.stderr.write('Timed out waiting for Review CI acknowledgement\n')
611
+ context.stderr.write('Timed out waiting for Review CI readiness\n')
468
612
  return 2
469
613
  }
470
614
 
471
615
  const completeLogin = ({ context, auth, apiUrl }) => {
472
616
  const existingConfig = { ...context.readConfig() }
473
- const org = auth.org || (auth.repo ? parseRepo(auth.repo).owner : null)
617
+ const repo = auth.repo ? parseRepo(auth.repo).fullName : null
618
+ const org = auth.org || (repo ? parseRepo(repo).owner : null)
474
619
 
475
620
  if (!org) throw new Error('Login response was missing org')
476
- delete existingConfig.repo
477
621
 
478
622
  const expiresAt = new Date(
479
623
  toMillis(context.now()) + auth.expires_in * 1000,
480
624
  ).toISOString()
625
+ const tokensByOrg = { ...(existingConfig.tokensByOrg || {}) }
626
+ const tokensByRepo = { ...(existingConfig.tokensByRepo || {}) }
627
+
628
+ if (existingConfig.token) {
629
+ const legacyEntry = {
630
+ token: existingConfig.token,
631
+ ...(existingConfig.githubLogin
632
+ ? { githubLogin: existingConfig.githubLogin }
633
+ : {}),
634
+ ...(existingConfig.expiresAt ? { expiresAt: existingConfig.expiresAt } : {}),
635
+ }
636
+
637
+ if (existingConfig.repo) {
638
+ try {
639
+ const legacyRepo = parseRepo(existingConfig.repo)
640
+ tokensByRepo[legacyRepo.fullName] = {
641
+ ...legacyEntry,
642
+ org: legacyRepo.owner,
643
+ repo: legacyRepo.fullName,
644
+ }
645
+ } catch (e) {
646
+ // Ignore malformed legacy scope and keep the new login usable.
647
+ }
648
+ } else if (existingConfig.org) {
649
+ tokensByOrg[existingConfig.org] = {
650
+ ...legacyEntry,
651
+ org: existingConfig.org,
652
+ }
653
+ } else {
654
+ // An unscoped legacy token has no safe owner to migrate.
655
+ }
656
+ }
657
+
658
+ const entry = {
659
+ token: auth.token,
660
+ githubLogin: auth.github_login,
661
+ expiresAt,
662
+ org,
663
+ ...(repo ? { repo } : {}),
664
+ }
665
+ if (repo) {
666
+ tokensByRepo[repo] = entry
667
+ } else {
668
+ tokensByOrg[org] = entry
669
+ }
481
670
 
482
- context.writeConfig({
671
+ const nextConfig = {
483
672
  ...existingConfig,
484
673
  apiUrl,
485
674
  org,
675
+ ...(repo ? { repo } : {}),
486
676
  token: auth.token,
487
677
  githubLogin: auth.github_login,
488
678
  expiresAt,
489
- })
490
- context.stdout.write(`Authenticated ${auth.github_login} for ${org}\n`)
679
+ tokensByOrg,
680
+ tokensByRepo,
681
+ }
682
+ if (!repo) delete nextConfig.repo
683
+ if (!Object.keys(tokensByOrg).length) delete nextConfig.tokensByOrg
684
+ if (!Object.keys(tokensByRepo).length) delete nextConfig.tokensByRepo
685
+
686
+ context.writeConfig(nextConfig)
687
+ context.stdout.write(`Authenticated ${auth.github_login} for ${repo || org}\n`)
491
688
  context.stdout.write(
492
- 'Agent setup: run `toast review-ci instructions --install-codex` to install Review CI guidance.\n',
689
+ 'Agent setup: run `toast review-ci instructions --install` to install Review CI guidance.\n',
493
690
  )
494
691
  }
495
692
 
@@ -646,6 +843,9 @@ const handleAuth = async context => {
646
843
 
647
844
  const readReviewCiSkill = () => fs.readFileSync(REVIEW_CI_SKILL_PATH, 'utf8')
648
845
 
846
+ const stripSkillFrontmatter = markdown =>
847
+ markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n)*/, '')
848
+
649
849
  const getCodexSkillPath = env =>
650
850
  path.join(
651
851
  env.CODEX_HOME || path.join(os.homedir(), '.codex'),
@@ -659,12 +859,118 @@ const installCodexSkill = ({ env, stdout }) => {
659
859
 
660
860
  fs.mkdirSync(path.dirname(skillPath), { recursive: true, mode: 0o700 })
661
861
  fs.writeFileSync(skillPath, readReviewCiSkill(), { mode: 0o600 })
662
- stdout.write(`Installed Review CI agent instructions to ${skillPath}\n`)
862
+ stdout.write(`Installed Review CI Codex skill to ${skillPath}\n`)
863
+ }
864
+
865
+ const buildMarkdownInstructionsSection = () =>
866
+ `${REVIEW_CI_INSTRUCTIONS_START}\n${stripSkillFrontmatter(
867
+ readReviewCiSkill(),
868
+ ).trim()}\n${REVIEW_CI_INSTRUCTIONS_END}\n`
869
+
870
+ const getMalformedMarkerError = fileName =>
871
+ `${fileName} has a Toast Review CI start marker without an end marker; repair the marked section and rerun.`
872
+
873
+ const removeMarkedInstructionFragments = ({ content, fileName }) => {
874
+ let next = content
875
+
876
+ while (next.includes(REVIEW_CI_INSTRUCTIONS_START)) {
877
+ const start = next.indexOf(REVIEW_CI_INSTRUCTIONS_START)
878
+ const end = next.indexOf(REVIEW_CI_INSTRUCTIONS_END, start)
879
+
880
+ if (end === -1) {
881
+ throw new Error(getMalformedMarkerError(fileName))
882
+ }
883
+
884
+ next = `${next.slice(0, start)}${next.slice(
885
+ end + REVIEW_CI_INSTRUCTIONS_END.length,
886
+ )}`
887
+ }
888
+
889
+ return next.split(REVIEW_CI_INSTRUCTIONS_END).join('')
890
+ }
891
+
892
+ const upsertMarkedSection = ({ content, fileName, section }) => {
893
+ const start = content.indexOf(REVIEW_CI_INSTRUCTIONS_START)
894
+ const end = content.indexOf(REVIEW_CI_INSTRUCTIONS_END, start)
895
+
896
+ if (start !== -1) {
897
+ if (end === -1) {
898
+ throw new Error(getMalformedMarkerError(fileName))
899
+ }
900
+
901
+ const cleanedBefore = removeMarkedInstructionFragments({
902
+ content: content.slice(0, start),
903
+ fileName,
904
+ })
905
+ const before = cleanedBefore.trim()
906
+ ? `${cleanedBefore.replace(/\s*$/, '')}\n\n`
907
+ : ''
908
+ const after = removeMarkedInstructionFragments({
909
+ content: content.slice(end + REVIEW_CI_INSTRUCTIONS_END.length),
910
+ fileName,
911
+ }).replace(/^\s*/, '')
912
+
913
+ return `${before}${section}${after ? `\n${after}` : ''}`
914
+ }
915
+
916
+ const cleaned = removeMarkedInstructionFragments({ content, fileName })
917
+ const prefix = cleaned.trim() ? `${cleaned.replace(/\s*$/, '')}\n\n` : ''
918
+
919
+ return `${prefix}${section}`
920
+ }
921
+
922
+ const getMarkdownInstructionsUpdate = ({ cwd, fileName }) => {
923
+ const filePath = path.join(cwd, fileName)
924
+ const exists = fs.existsSync(filePath)
925
+ const existing = exists ? fs.readFileSync(filePath, 'utf8') : ''
926
+ const next = upsertMarkedSection({
927
+ content: existing,
928
+ fileName,
929
+ section: buildMarkdownInstructionsSection(),
930
+ })
931
+
932
+ return {
933
+ exists,
934
+ fileName,
935
+ filePath,
936
+ next,
937
+ }
938
+ }
939
+
940
+ const writeMarkdownInstructionsUpdate = ({ update, stdout }) => {
941
+ if (update.exists) {
942
+ fs.writeFileSync(update.filePath, update.next)
943
+ } else {
944
+ fs.writeFileSync(update.filePath, update.next, { mode: 0o644 })
945
+ }
946
+ stdout.write(
947
+ `Installed Review CI ${update.fileName} instructions to ${update.filePath}\n`,
948
+ )
949
+ }
950
+
951
+ const installAgentInstructions = ({ cwd, env, stdout }) => {
952
+ const markdownUpdates = ['CLAUDE.md', 'AGENTS.md'].map(fileName =>
953
+ getMarkdownInstructionsUpdate({ cwd, fileName }),
954
+ )
955
+
956
+ markdownUpdates.forEach(update =>
957
+ writeMarkdownInstructionsUpdate({ update, stdout }),
958
+ )
959
+ installCodexSkill({ env, stdout })
663
960
  }
664
961
 
665
962
  const handleInstructions = async context => {
666
963
  const { options } = parseArgs(context.commandArgs || [])
667
964
 
965
+ if (options.install || options['install-agent']) {
966
+ installAgentInstructions({
967
+ cwd: context.cwd,
968
+ env: context.env,
969
+ stdout: context.stdout,
970
+ })
971
+ return 0
972
+ }
973
+
668
974
  if (options['install-codex']) {
669
975
  installCodexSkill({ env: context.env, stdout: context.stdout })
670
976
  return 0
@@ -677,11 +983,11 @@ const handleInstructions = async context => {
677
983
  const printHelp = stdout => {
678
984
  stdout.write(`Usage:
679
985
  toast auth [--org ORG] [--device|--github-token TOKEN]
680
- toast review-ci auth --repo OWNER/REPO [--device|--github-token TOKEN]
681
- toast review-ci status --repo OWNER/REPO --pr NUMBER --head SHA
682
- toast review-ci next --repo OWNER/REPO --pr NUMBER --head SHA
683
- toast review-ci wait --repo OWNER/REPO --pr NUMBER --head SHA
684
- toast review-ci instructions [--install-codex]
986
+ toast review-ci auth --repo ORG/REPO [--device|--github-token TOKEN]
987
+ toast review-ci status --repo ORG/REPO --pr NUMBER --head SHA
988
+ toast review-ci next --repo ORG/REPO --pr NUMBER --head SHA
989
+ toast review-ci wait --repo ORG/REPO --pr NUMBER --head SHA
990
+ toast review-ci instructions [--install|--install-agent|--install-codex]
685
991
  `)
686
992
  }
687
993
 
@@ -707,6 +1013,7 @@ const main = async ({
707
1013
  createBrowserCallbackServer = createBrowserCallbackServerDefault,
708
1014
  openBrowser = openBrowserDefault,
709
1015
  getGitRemoteUrl = getGitRemoteUrlDefault,
1016
+ cwd = process.cwd(),
710
1017
  stdout = process.stdout,
711
1018
  stderr = process.stderr,
712
1019
  }) => {
@@ -721,6 +1028,7 @@ const main = async ({
721
1028
  createBrowserCallbackServer,
722
1029
  openBrowser,
723
1030
  getGitRemoteUrl,
1031
+ cwd,
724
1032
  stdout,
725
1033
  stderr,
726
1034
  }