SLOPSHOPPER

codebuild

Build any repo on AWS CodeBuild from Claude Code: pick an AWS profile, press Build. Sets itself up, generates the buildspec, streams logs into a pane, and lets…

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codebuild
│ ┃ CodeBuild setup ✕ › fix the failing auth test and add an audit log call │ ┃ ☁️ Build on AWS CodeBuild │ ┃ AWS profile: default ⏎ Check ⏺ Read(src/auth.ts) │ ┃ ✓ account undefined · us-east-1 · ⎿ Read 6 lines │ ┃ undefined ⏺ Update(src/auth.ts) │ ┃ Repo: /work/app ⏎ Use ⎿ Added 2 lines, removed 1 line │ ┃ node (npm) · buildspec generated for you ⏺ Bash(bun test) │ ┃ First run creates: a private S3 bucket ⎿ 3 pass, 1 fail │ ┃ (sources expire in 7 days), one │ ┃ least-privilege IAM role, one CodeBuild ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ project. │ ┃ ~$0.005/min · small Linux machine ✻ Worked for 42s · done 4:20 PM │ ┃ [ Set up & build ] [ Show buildspec ] [ Clos │ ┃ or just run /codebuild again · Esc returns › /codebuild │ ┃ to the prompt ⎿ codebuild: Ready with profile default (account undefined · us-ea │ ⎿ codebuild: Run /codebuild again to set up and build, or switch w │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CodeBuild setup
☁️ Build on AWS CodeBuild AWS profile: default ⏎ Check ✓ account undefined · us-east-1 · undefined Repo: /work/app ⏎ Use node (npm) · buildspec generated for you First run creates: a private S3 bucket (sources expire in 7 days), one least-privilege IAM role, one CodeBuild project. ~$0.005/min · small Linux machine [ Set up & build ] [ Show buildspec ] [ Close ] or just run /codebuild again · Esc returns to the prompt
README

☁️ codebuild

Build any repo on AWS CodeBuild without leaving Claude Code. Pick an AWS profile, press Build. No console, no YAML, no CI to set up.

<img src="../../../assets/screens/codebuild.svg" alt="codebuild in a real Claude Code session: Claude fixed a failing test and the rebuild passed" width="100%">

<sub>A real session: build #1 failed, Claude read the log, fixed the code and built again through its tool. It passed in 24s, for about $0.005.</sub>

☁️ Build on AWS CodeBuild
  AWS profile   [ dev ▾ ]
    ✓ account 123456789012 · us-west-2 · assumed-role/Dev/me
  Repo          [ /work/todo-api ]
    node (npm) · buildspec generated for you
    First run creates: a private S3 bucket (sources expire in 7 days), one least-privilege IAM role, one CodeBuild project.
    ~$0.005/min · small Linux machine
  [ Set up & build ]  [ Show buildspec ]  [ Close ]

☁️ ❌ #3 failed in BUILD · 1m 30s
  claude-todo-api-4f2a1c · started by claude · BUILD: COMMAND_EXECUTION_ERROR
  ✓ submitted 1s  ✓ provisioning 9s  ✓ install 12s  ✗ build 41s
  [Container] Running command npm test
  not ok 1 - adds a todo
  [ Ask Claude to fix ]  [ Build again ]  [ Settings ]  [ Close ]
  Open in the AWS console

The only things it needs from you are an AWS profile and the repo to build. It works out the rest.

Features

  • One-screen setup. /codebuild checks every AWS profile you have, preselects the first one with working credentials, and shows its account, region and role, plus which other profiles work, before anything happens. Run /codebuild again to go; there's nothing to click. The first build creates what it needs and tags it created-by=claude-codebuild-mod:
  • a private S3 bucket per region, whose uploaded sources expire after 7 days
  • one IAM role per account that can only write claude-* build logs and read the uploaded sources
  • one CodeBuild project per repo

After that, /codebuild builds straight away.

  • Builds what you have now. The working tree is zipped with git, uncommitted and untracked changes included. .gitignore is respected, and .env files, keys and .npmrc/.netrc are never uploaded. A subfolder of a repo works too, for monorepos. Folders that aren't git repos are zipped without .git, node_modules and secrets.
  • No buildspec needed. If the repo has a buildspec.yml, it's used. Otherwise one is generated for npm, pnpm, yarn, Python, Go, Rust, Maven, Gradle, .NET, Ruby, Make or Docker, with runtime versions read from .nvmrc and .python-version. /codebuild buildspec save writes it into the repo.
  • Live in a pane. Each phase shows with its time, along with a live log tail and a link to the console. The status line reads ☁️ #3 · BUILD · 1m 20s, and a toast says when it's done and what it cost.
  • Claude can build too. Claude has a build tool. Ask "make sure this passes on CodeBuild" and Claude runs it, reads the failing log lines, fixes the code and builds again. It asks you first (Build / Allow this session / Don't build), and refuses when nobody can be asked.
  • Ask Claude to fix. One press on a failed build hands Claude the failing phase and the last 80 log lines.
  • Signs you in. If no profile has working credentials, /codebuild login (or Sign me in) runs aws sso login for SSO profiles. Otherwise it asks Claude to get credentials with whatever your environment provides, listing exactly the permissions it needs. When Claude is done, setup re-checks your profiles and moves on.
  • Errors you can act on. An expired SSO session tells you the aws sso login --profile … command to run. A missing permission names the IAM action. A missing profile or CLI says how to fix it.
  • Clean exit. /codebuild teardown deletes this repo's project, log group and uploaded sources after you confirm. teardown all also deletes the shared role and bucket.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install codebuild@awesome-claude-mods

It needs the AWS CLI (v2), signed in any way the CLI supports: SSO, access keys, credential_process or assume-role profiles.

Commands

CommandWhat it does
/codebuildBuild this repo. The first time, it opens setup with a working profile already picked; run it again to set up and build
/codebuild <path>The same, for another folder
/codebuild login [profile]Sign in: runs aws sso login for an SSO profile, otherwise asks Claude to get credentials however your environment provides them
/codebuild profile <name>Switch AWS profile without the dropdown
/codebuild fixHand the failed build's log to Claude to fix
/codebuild setupChange the profile or repo
/codebuild status · logs · stopThe running build
/codebuild buildspec · buildspec saveSee or save the generated buildspec
/codebuild teardown · teardown allDelete what it created

Configuration

OptionDefaultWhat it does
profileemptyProfile for repos not set up yet. Empty: AWS_PROFILE, else default
regionemptyEmpty: the profile's region, else us-east-1
computeTypeBUILD_GENERAL1_SMALLSMALL, MEDIUM or LARGE
imageaws/codebuild/amazonlinux-x86_64-standard:5.0Image for new projects

What it asks your AWS identity to do

WhenActions
Every buildsts:GetCallerIdentity, s3:PutObject on the bucket, codebuild:StartBuild, BatchGetBuilds, StopBuild, logs:GetLogEvents
First build in an account or regions3:CreateBucket, PutBucketPublicAccessBlock, PutLifecycleConfiguration, PutBucketTagging, iam:CreateRole, PutRolePolicy, iam:PassRole (its own role), codebuild:CreateProject, BatchGetProjects

How it works

EventWhy
command.run/codebuild and its subcommands
tool.call on mcp__codebuild__buildClaude's tool: confirm, build, wait, report
ui.render on PaneThe setup screen and the live build
$.process.runaws and git
$.clock.everyFollows a build you started (every 4 seconds)
$.state, $.storeThe live build, and each repo's setup

Test it

claude plugin test mods/cloud/codebuild   # 26 tests, with the AWS CLI and git stubbed

Tested for real

In a personal AWS account in us-east-1, the first build created the bucket, role and project in about 20 seconds. It ran npm test on CodeBuild and streamed the failure into the pane. Ask Claude to fix gave Claude the log; Claude fixed the code and rebuilt through the tool, and it went green.

Limitations

  • Linux x86 builds only for now. There's no ARM or Windows, and no VPC or secrets configuration.
  • The cost shown is an estimate at us-east-1 on-demand prices, not your bill.
  • Uploads go through the AWS CLI, so a very large repo takes as long as its upload.
  • It builds what's on disk. Builds aren't triggered by pushes; use CodePipeline or GitHub Actions for that.
Source 4 files
hooks/register.tsx 732 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BuildView, Wizard } from '../types'
5import {
6  bucketName, costOf, duration, explainAwsError, isDone, isSecretPath, lifecycle, parseBuild, PRICE_PER_MINUTE,
7  projectInput, projectName, reportForClaude, requiredPermissions, ROLE, rolePolicy, shownPhases, signInRequest, statusText, TAG, trustPolicy, usefulLog,
8  type Config, type Target,
9} from './aws'
10import { planBuild, type Plan, type ProjectFiles } from './buildspec'
11
12const PANE = 'codebuild'
13const TOOL = 'mcp__codebuild__build'
14const LOG_LINES = 400
15
16const wizardAtom = atom({ plugin: 'codebuild', key: 'wizard' } as const, null)
17const buildAtom = atom({ plugin: 'codebuild', key: 'build' } as const, null)
18const logAtom = atom({ plugin: 'codebuild', key: 'log' } as const, [])
19const modeAtom = atom({ plugin: 'codebuild', key: 'mode' } as const, 'wizard')
20
21type Settings = { profile: string; region: string; computeType: string; image: string }
22type Active = { config: Config; startedBy: 'you' | 'claude'; plan: Plan; hasOwnBuildspec: boolean }
23
24// Module state: a reload starts these over; the running build is in $.state and $.store.
25let settings: Settings = { profile: '', region: '', computeType: 'BUILD_GENERAL1_SMALL', image: 'aws/codebuild/amazonlinux-x86_64-standard:5.0' }
26let active: Active | null = null
27let logToken: string | undefined
28let poller: { cancel: () => void } | null = null
29let isTicking = false
30let isClaudeAllowed = false
31// Set while Claude is getting credentials for the person; the next turn's end checks the profiles again.
32let isAwaitingSignIn = false
33
34type AwsOut = { ok: boolean; json: any; stdout: string; stderr: string }
35
36/** Runs the AWS CLI with the person's profile and region. Rejects only when the CLI is missing. */
37async function awsCli($: EngineInterface, t: { profile: string; region?: string }, args: string[], timeoutMs = 60_000): Promise<AwsOut> {
38  const argv = ['aws', ...args, '--output', 'json', '--no-cli-pager']
39  if (t.profile !== '') argv.push('--profile', t.profile)
40  if (t.region) argv.push('--region', t.region)
41  let ran
42  try {
43    ran = await $.process.run(argv, { timeoutMs })
44  } catch (error) {
45    if (/ENOENT|not found|No such file/i.test(String(error))) {
46      throw new Error('The AWS CLI isn\'t installed. Install it (https://aws.amazon.com/cli/), then run /codebuild again.')
47    }
48    throw error
49  }
50  let json: any
51  try {
52    json = ran.stdout.trim() === '' ? {} : JSON.parse(ran.stdout)
53  } catch {
54    json = {}
55  }
56  return { ok: ran.exitCode === 0, json, stdout: ran.stdout, stderr: ran.stderr }
57}
58
59/** Like awsCli, but a failure throws what the person should do about it. */
60async function awsMust($: EngineInterface, t: { profile: string; region?: string }, args: string[], timeoutMs?: number): Promise<any> {
61  const out = await awsCli($, t, args, timeoutMs)
62  if (!out.ok) throw new Error(explainAwsError(out.stderr, t.profile || 'default'))
63  return out.json
64}
65
66async function listProfiles($: EngineInterface): Promise<string[]> {
67  try {
68    const ran = await $.process.run(['aws', 'configure', 'list-profiles'], { timeoutMs: 10_000 })
69    return ran.stdout.split('\n').map(line => line.trim()).filter(Boolean)
70  } catch {
71    return []
72  }
73}
74
75/** Who the profile is: account, region, identity. */
76async function resolveTarget($: EngineInterface, profile: string): Promise<Target & { arn: string }> {
77  const who = await awsMust($, { profile, region: settings.region || undefined }, ['sts', 'get-caller-identity'], 30_000)
78  let region = settings.region
79  if (region === '') {
80    const configured = await awsCli($, { profile }, ['configure', 'get', 'region'], 10_000)
81    region = configured.stdout.trim()
82  }
83  if (region === '') region = (await $.env.get('AWS_REGION')) ?? (await $.env.get('AWS_DEFAULT_REGION')) ?? 'us-east-1'
84  return { profile, region, account: String(who.Account), arn: String(who.Arn) }
85}
86
87async function absoluteRepo($: EngineInterface, path: string): Promise<string> {
88  let repo = path.trim() === '' ? await $.session.root() : path.trim()
89  if (repo.startsWith('~')) repo = `${(await $.env.get('HOME')) ?? ''}${repo.slice(1)}`
90  if (!repo.startsWith('/')) repo = `${await $.session.root()}/${repo}`
91  return repo.replace(/\/+$/, '').replace(/\/\.\//g, '/') || '/'
92}
93
94/** What the repo is, and the buildspec CodeBuild will run. */
95async function inspectRepo($: EngineInterface, repo: string): Promise<{ plan: Plan; hasOwnBuildspec: boolean }> {
96  const names = (await $.fs.list(repo)).map(entry => entry.name)
97  const readText = async (name: string) => (names.includes(name) ? await $.fs.read(`${repo}/${name}`).catch(() => undefined) : undefined)
98  const files: ProjectFiles = { names }
99  const pkg = await readText('package.json')
100  if (pkg !== undefined) {
101    try {
102      files.packageJson = JSON.parse(pkg)
103    } catch {
104      files.packageJson = {}
105    }
106  }
107  files.nodeVersion = (await readText('.nvmrc')) ?? (await readText('.node-version'))
108  files.pythonVersion = await readText('.python-version')
109  files.makefile = await readText('Makefile')
110  const hasOwnBuildspec = names.includes('buildspec.yml') || names.includes('buildspec.yaml')
111  return { plan: planBuild(files), hasOwnBuildspec }
112}
113
114async function identityLine($: EngineInterface, profile: string): Promise<{ text: string; isOk: boolean }> {
115  try {
116    const t = await resolveTarget($, profile)
117    const who = t.arn.split(':').pop() ?? t.arn
118    return { text: `✓ account ${t.account} · ${t.region} · ${who}`, isOk: true }
119  } catch (error) {
120    return { text: `✗ ${error instanceof Error ? error.message : String(error)}`, isOk: false }
121  }
122}
123
124type Probe = { profile: string; isOk: boolean; text: string }
125
126/** Checks profiles side by side (at most 12), so setup can preselect one that works. */
127async function probeProfiles($: EngineInterface, profiles: readonly string[]): Promise<Probe[]> {
128  return Promise.all(profiles.slice(0, 12).map(async profile => ({ profile, ...(await identityLine($, profile)) })))
129}
130
131/** First working profile by preference: remembered, AWS_PROFILE, the option, default, then any. */
132function choosePreferred(probes: readonly Probe[], preferred: readonly string[]): Probe | undefined {
133  for (const name of preferred) {
134    const hit = probes.find(p => p.profile === name && p.isOk)
135    if (hit !== undefined) return hit
136  }
137  return probes.find(p => p.isOk)
138}
139
140const othersLine = (probes: readonly Probe[], chosen: string) =>
141  probes.filter(p => p.profile !== chosen).map(p => `${p.profile} ${p.isOk ? '✓' : '✗'}`).join(' · ')
142
143/** Opens the one-screen setup: profile, repo, what it will create. */
144async function openWizard($: EngineInterface, repoArg: string): Promise<void> {
145  const repo = await absoluteRepo($, repoArg)
146  const saved = (await $.store.get(`repo:${repo}`)) as Config | undefined
147  const profiles = await listProfiles($)
148  const envProfile = (await $.env.get('AWS_PROFILE')) ?? ''
149  const preferred = [saved?.profile ?? '', envProfile, settings.profile, 'default'].filter(Boolean)
150  const guess = preferred.find(p => profiles.includes(p)) ?? profiles[0] ?? preferred[0] ?? ''
151  const { plan, hasOwnBuildspec } = await inspectRepo($, repo).catch(() => ({ plan: planBuild({ names: [] }), hasOwnBuildspec: false }))
152  await update($, modeAtom, () => 'wizard')
153  await update($, wizardAtom, () => ({
154    profiles, profile: guess, repo, identity: profiles.length > 1 ? `checking ${profiles.length} profiles…` : 'checking AWS…', isIdentityOk: false, others: '', isSso: false,
155    stack: plan.stack.join(' + '), buildspec: plan.yaml, hasOwnBuildspec, isConfigured: saved !== undefined, showBuildspec: false, message: '', isBusy: false,
156  }))
157  // Setup takes the keyboard, so its hotkeys work at once; Esc hands it back.
158  await $.ui.open({ id: PANE, title: 'CodeBuild setup', focus: true, closeOnEscape: true })
159  const probes = profiles.length > 0 ? await probeProfiles($, profiles) : [{ profile: guess, ...(await identityLine($, guess)) }]
160  const chosen = choosePreferred(probes, preferred) ?? probes.find(p => p.profile === guess) ?? probes[0]
161  if (chosen === undefined) return
162  const isSso = chosen.isOk ? false : await usesSso($, chosen.profile)
163  await update($, wizardAtom, w => (w === null ? w : { ...w, profile: chosen.profile, identity: chosen.text, isIdentityOk: chosen.isOk, others: othersLine(probes, chosen.profile), isSso }))
164}
165
166async function pickProfile($: EngineInterface, profile: string): Promise<void> {
167  await update($, wizardAtom, w => (w === null ? w : { ...w, profile, identity: 'checking AWS…', isIdentityOk: false, message: '' }))
168  const who = await identityLine($, profile)
169  const isSso = who.isOk ? false : await usesSso($, profile)
170  await update($, wizardAtom, w => (w === null ? w : { ...w, identity: who.text, isIdentityOk: who.isOk, isSso }))
171}
172
173async function pickRepo($: EngineInterface, path: string): Promise<void> {
174  const repo = await absoluteRepo($, path)
175  const kind = await $.fs.stat(repo).then(s => s.kind).catch(() => 'missing')
176  if (kind !== 'dir') {
177    await update($, wizardAtom, w => (w === null ? w : { ...w, message: `✗ ${repo} isn't a folder.` }))
178    return
179  }
180  const { plan, hasOwnBuildspec } = await inspectRepo($, repo)
181  const saved = await $.store.get(`repo:${repo}`)
182  await update($, wizardAtom, w => (w === null ? w : { ...w, repo, stack: plan.stack.join(' + '), buildspec: plan.yaml, hasOwnBuildspec, isConfigured: saved !== undefined, message: '' }))
183}
184
185/** Creates what's missing (bucket, role, project) and remembers the repo's setup. */
186async function ensureSetup($: EngineInterface, repo: string, profile: string, plan: Plan): Promise<Config> {
187  const t = await resolveTarget($, profile)
188  const c: Config = { profile, region: t.region, account: t.account, repo, project: projectName(repo), bucket: bucketName(t.account, t.region), role: ROLE }
189
190  if (!(await awsCli($, c, ['s3api', 'head-bucket', '--bucket', c.bucket])).ok) {
191    const create = ['s3api', 'create-bucket', '--bucket', c.bucket]
192    if (c.region !== 'us-east-1') create.push('--create-bucket-configuration', `LocationConstraint=${c.region}`)
193    await awsMust($, c, create)
194    await awsMust($, c, ['s3api', 'put-public-access-block', '--bucket', c.bucket, '--public-access-block-configuration', 'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'])
195    await awsMust($, c, ['s3api', 'put-bucket-lifecycle-configuration', '--bucket', c.bucket, '--lifecycle-configuration', JSON.stringify(lifecycle)])
196    await awsMust($, c, ['s3api', 'put-bucket-tagging', '--bucket', c.bucket, '--tagging', JSON.stringify({ TagSet: [TAG] })])
197  }
198
199  if (!(await awsCli($, c, ['iam', 'get-role', '--role-name', ROLE])).ok) {
200    await awsMust($, c, ['iam', 'create-role', '--role-name', ROLE, '--description', 'Lets CodeBuild run builds started by the codebuild mod for Claude Code', '--assume-role-policy-document', JSON.stringify(trustPolicy(c.account)), '--tags', `Key=${TAG.Key},Value=${TAG.Value}`])
201    await awsMust($, c, ['iam', 'put-role-policy', '--role-name', ROLE, '--policy-name', 'claude-codebuild-run', '--policy-document', JSON.stringify(rolePolicy(c.account))])
202  }
203
204  const found = await awsMust($, c, ['codebuild', 'batch-get-projects', '--names', c.project])
205  if ((found.projects ?? []).length === 0) {
206    const input = JSON.stringify(projectInput(c, { computeType: settings.computeType, image: settings.image, privileged: plan.needsDocker }))
207    // A new role takes a few seconds to be assumable: retry while IAM catches up.
208    for (let attempt = 0; ; attempt++) {
209      const made = await awsCli($, c, ['codebuild', 'create-project', '--cli-input-json', input])
210      if (made.ok) break
211      if (attempt >= 8 || !/not authorized to perform: sts:AssumeRole|InvalidInputException.*role/i.test(made.stderr)) {
212        throw new Error(explainAwsError(made.stderr, profile))
213      }
214      await $.process.run(['sleep', '5'], { timeoutMs: 10_000 })
215    }
216  }
217
218  await $.store.set(`repo:${repo}`, c)
219  return c
220}
221
222/** Zips the working tree as git sees it (uncommitted and untracked included, ignored and secret files left out). */
223async function packageSource($: EngineInterface, repo: string): Promise<{ zip: string; dir: string; bytes: number }> {
224  const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
225  const dir = `${tmp}/claude-codebuild-${(await $.clock.now()).toString(36)}`
226  const zip = `${dir}/source.zip`
227  await $.process.run(['mkdir', '-p', dir])
228
229  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: repo })
230  if (top.exitCode !== 0) {
231    const zipped = await $.process.run(['zip', '-r', '-q', '-X', zip, '.', '-x', '.git/*', 'node_modules/*', '*/node_modules/*', '.env', '.env.*', '*.pem', '*.key', 'id_rsa*'], { cwd: repo, timeoutMs: 300_000 })
232    if (zipped.exitCode !== 0) throw new Error(`Couldn't zip ${repo}: ${zipped.stderr.slice(0, 200)}`)
233  } else {
234    const root = top.stdout.trim()
235    const prefix = (await $.process.run(['git', 'rev-parse', '--show-prefix'], { cwd: repo })).stdout.trim()
236    const env = { GIT_INDEX_FILE: `${dir}/index` }
237    const hasHead = (await $.process.run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: root })).exitCode === 0
238    await $.process.run(['git', 'read-tree', hasHead ? 'HEAD' : '--empty'], { cwd: root, env })
239    const added = await $.process.run(['git', 'add', '-A', '--', '.'], { cwd: root, env, timeoutMs: 300_000 })
240    if (added.exitCode !== 0) throw new Error(`git couldn't read the working tree: ${added.stderr.slice(0, 200)}`)
241    const listed = await $.process.run(['git', 'ls-files', '-z'], { cwd: root, env })
242    const secrets = listed.stdout.split('\0').filter(path => path !== '' && isSecretPath(path))
243    if (secrets.length > 0) {
244      await $.process.run(['git', '--literal-pathspecs', 'rm', '--cached', '-q', '--ignore-unmatch', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd: root, env, stdin: secrets.join('\0') })
245    }
246    const tree = (await $.process.run(['git', 'write-tree', ...(prefix === '' ? [] : [`--prefix=${prefix}`])], { cwd: root, env })).stdout.trim()
247    const archived = await $.process.run(['git', 'archive', '--format=zip', '-o', zip, tree], { cwd: root, timeoutMs: 300_000 })
248    if (archived.exitCode !== 0) throw new Error(`git couldn't zip the source: ${archived.stderr.slice(0, 200)}`)
249  }
250  const bytes = (await $.fs.stat(zip)).size
251  return { zip, dir, bytes }
252}
253
254/** Packages, uploads and starts one build; the pane and status line follow it. */
255async function launch($: EngineInterface, c: Config, startedBy: 'you' | 'claude'): Promise<BuildView> {
256  const { plan, hasOwnBuildspec } = await inspectRepo($, c.repo)
257  $.ui.status('☁️ packaging…')
258  const source = await packageSource($, c.repo)
259  const key = `sources/${c.project}/${await $.clock.now()}.zip`
260  $.ui.status(`☁️ uploading ${(source.bytes / 1_048_576).toFixed(1)} MB…`)
261  await awsMust($, c, ['s3', 'cp', source.zip, `s3://${c.bucket}/${key}`, '--only-show-errors'], 600_000)
262  await $.process.run(['rm', '-f', source.zip, `${source.dir}/index`])
263  await $.process.run(['rmdir', source.dir]).catch(() => undefined)
264
265  const args = ['codebuild', 'start-build', '--project-name', c.project, '--source-type-override', 'S3', '--source-location-override', `${c.bucket}/${key}`,
266    '--buildspec-override', hasOwnBuildspec ? 'buildspec.yml' : plan.yaml, '--compute-type-override', settings.computeType]
267  if (plan.needsDocker) args.push('--privileged-mode-override')
268  const started = await awsMust($, c, args)
269  const view = parseBuild(started.build ?? {}, c, startedBy)
270
271  active = { config: c, startedBy, plan, hasOwnBuildspec }
272  logToken = undefined
273  await $.store.set('active', { config: c, startedBy })
274  await update($, logAtom, () => [])
275  await update($, buildAtom, () => view)
276  await update($, modeAtom, () => 'build')
277  $.ui.status(statusText(view, await $.clock.now()))
278  return view
279}
280
281async function fetchLogs($: EngineInterface, view: BuildView): Promise<void> {
282  if (active === null || view.logGroup === undefined || view.logStream === undefined) return
283  for (let page = 0; page < 3; page++) {
284    const args = ['logs', 'get-log-events', '--log-group-name', view.logGroup, '--log-stream-name', view.logStream, '--start-from-head', '--limit', '500']
285    if (logToken !== undefined) args.push('--next-token', logToken)
286    const out = await awsCli($, active.config, args, 30_000)
287    if (!out.ok) return
288    const lines = (out.json.events ?? []).flatMap((event: { message?: string }) => String(event.message ?? '').replace(/\x1b\[[0-9;]*m/g, '').replace(/\n$/, '').split('\n'))
289    const nextToken = out.json.nextForwardToken as string | undefined
290    if (lines.length > 0) await update($, logAtom, log => [...log, ...lines].slice(-LOG_LINES))
291    if (nextToken === undefined || nextToken === logToken || lines.length === 0) {
292      logToken = nextToken ?? logToken
293      return
294    }
295    logToken = nextToken
296  }
297}
298
299/** Reads the build's progress and new log lines into the pane. */
300async function refreshBuild($: EngineInterface): Promise<BuildView | null> {
301  const current = await read($, buildAtom)
302  if (active === null || current === null) return current
303  const out = await awsMust($, active.config, ['codebuild', 'batch-get-builds', '--ids', current.id], 30_000)
304  const view = parseBuild((out.builds ?? [])[0] ?? {}, active.config, active.startedBy)
305  await update($, buildAtom, () => view)
306  await fetchLogs($, view)
307  $.ui.status(statusText(view, await $.clock.now()))
308  return view
309}
310
311/** CloudWatch delivers the last lines a few seconds after a build ends: read until the log stops growing. */
312async function settleLogs($: EngineInterface, view: BuildView): Promise<void> {
313  let quiet = 0
314  for (let round = 0; round < 8 && quiet < 2; round++) {
315    const before = (await read($, logAtom)).length
316    await fetchLogs($, view)
317    quiet = (await read($, logAtom)).length === before ? quiet + 1 : 0
318    if (quiet < 2) await $.process.run(['sleep', '2'], { timeoutMs: 10_000 })
319  }
320}
321
322async function finishBuild($: EngineInterface, view: BuildView): Promise<void> {
323  poller?.cancel()
324  poller = null
325  await settleLogs($, view)
326  await $.store.delete('active')
327  const took = duration((view.endedAt ?? view.startedAt) - view.startedAt)
328  const cost = costOf((view.endedAt ?? view.startedAt) - view.startedAt, settings.computeType).toFixed(3)
329  $.ui.toast(view.status === 'SUCCEEDED' ? `CodeBuild #${view.number} passed in ${took} (~$${cost})` : `CodeBuild #${view.number} ${view.status.toLowerCase()} in ${took}: ${view.failure ?? ''}`, { timeoutMs: 8000 })
330}
331
332async function tickBuild($: EngineInterface): Promise<void> {
333  if (isTicking) return
334  isTicking = true
335  try {
336    const view = await refreshBuild($)
337    if (view !== null && isDone(view)) await finishBuild($, view)
338  } catch (error) {
339    $.ui.status(`☁️ ${error instanceof Error ? error.message : String(error)}`.slice(0, 120))
340  } finally {
341    isTicking = false
342  }
343}
344
345function followBuild($: EngineInterface): void {
346  poller?.cancel()
347  poller = $.clock.every(4000, () => void tickBuild($))
348}
349
350/** Whether a profile signs in through IAM Identity Center (aws sso login). */
351async function usesSso($: EngineInterface, profile: string): Promise<boolean> {
352  if (profile === '') return false
353  for (const key of ['sso_session', 'sso_start_url']) {
354    const found = await awsCli($, { profile }, ['configure', 'get', key], 10_000).catch(() => undefined)
355    if (found !== undefined && found.ok && found.stdout.trim() !== '') return true
356  }
357  return false
358}
359
360/** Signs the person in: aws sso login where the profile uses SSO, else Claude gets credentials its own way. */
361async function signIn($: EngineInterface, profileArg: string): Promise<string> {
362  const w = await read($, wizardAtom)
363  const profile = profileArg || w?.profile || settings.profile || 'default'
364  if (await usesSso($, profile)) {
365    await update($, wizardAtom, x => (x === null ? x : { ...x, message: `Signing in to ${profile}: finish in the browser tab that opened…` }))
366    const ran = await $.process.run(['aws', 'sso', 'login', '--profile', profile], { timeoutMs: 300_000 }).catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
367    if (ran.exitCode !== 0) return `✗ aws sso login failed: ${explainAwsError(ran.stderr, profile)}`
368    await recheckProfiles($)
369    return `Signed in to ${profile}. Run /codebuild to build.`
370  }
371  isAwaitingSignIn = true
372  const listed = w === null ? '' : [`${w.profile} ✗`, w.others].filter(Boolean).join(' · ')
373  await update($, wizardAtom, x => (x === null ? x : { ...x, message: 'Asked Claude to get AWS credentials; setup checks again when it\'s done.' }))
374  await $.prompt.submit({ text: signInRequest(listed, requiredPermissions('')) })
375  return 'Asked Claude to get you AWS credentials.'
376}
377
378/** Probes every profile again and moves setup onto the first one that works. */
379async function recheckProfiles($: EngineInterface): Promise<void> {
380  const w = await read($, wizardAtom)
381  if (w === null) return
382  await openWizard($, w.repo)
383  const after = await read($, wizardAtom)
384  if (after !== null && after.isIdentityOk) $.ui.toast(`AWS profile ${after.profile} works now: run /codebuild to build`, { timeoutMs: 8000 })
385}
386
387/** The wizard's button: set up what's missing, then build. */
388async function setupAndBuild($: EngineInterface): Promise<void> {
389  const w = await read($, wizardAtom)
390  if (w === null || w.isBusy) return
391  if (!w.isIdentityOk) {
392    await update($, wizardAtom, x => (x === null ? x : { ...x, message: `✗ Pick a profile with working credentials first (/codebuild profile <name> also works).` }))
393    return
394  }
395  await update($, wizardAtom, x => (x === null ? x : { ...x, isBusy: true, message: w.isConfigured ? 'Starting the build…' : 'Setting up (first time takes ~20s)…' }))
396  try {
397    const { plan } = await inspectRepo($, w.repo)
398    const c = await ensureSetup($, w.repo, w.profile, plan)
399    await launch($, c, 'you')
400    await update($, wizardAtom, x => (x === null ? x : { ...x, isBusy: false, isConfigured: true, message: '' }))
401    followBuild($)
402  } catch (error) {
403    await update($, wizardAtom, x => (x === null ? x : { ...x, isBusy: false, message: `✗ ${error instanceof Error ? error.message : String(error)}` }))
404  }
405}
406
407async function stopBuild($: EngineInterface): Promise<string> {
408  const view = await read($, buildAtom)
409  if (active === null || view === null || isDone(view)) return 'No build is running.'
410  await awsMust($, active.config, ['codebuild', 'stop-build', '--id', view.id])
411  await tickBuild($)
412  return `Stopping build #${view.number}.`
413}
414
415async function askClaudeToFix($: EngineInterface): Promise<void> {
416  const view = await read($, buildAtom)
417  if (view === null) return
418  const log = await read($, logAtom)
419  await $.prompt.submit({ text: `My AWS CodeBuild build failed. Find the cause and fix it, then build again with the codebuild tool.\n\n${reportForClaude(view, log, settings.computeType)}` })
420}
421
422async function saveBuildspec($: EngineInterface, repo: string): Promise<string> {
423  const { plan, hasOwnBuildspec } = await inspectRepo($, repo)
424  if (hasOwnBuildspec) return `${repo} already has a buildspec.yml; it's used as is.`
425  await $.fs.write(`${repo}/buildspec.yml`, plan.yaml)
426  return `Wrote ${repo}/buildspec.yml (${plan.stack.join(' + ')}). Edit it and the next build uses it.`
427}
428
429/** Deletes what the mod created for this repo; the shared role and bucket only with `all`. */
430async function teardown($: EngineInterface, repo: string, isAll: boolean): Promise<string> {
431  const c = (await $.store.get(`repo:${repo}`)) as Config | undefined
432  if (c === undefined) return `Nothing was set up for ${repo}.`
433  const what = [`CodeBuild project ${c.project}`, `log group /aws/codebuild/${c.project}`, `uploaded sources s3://${c.bucket}/sources/${c.project}/`]
434  if (isAll) what.push(`IAM role ${ROLE}`, `S3 bucket ${c.bucket} (if empty)`)
435  let answer = 'Keep them'
436  try {
437    answer = await $.ui.ask(`Delete these from account ${c.account} (${c.region})?\n\n${what.map(x => `  • ${x}`).join('\n')}`, { header: 'codebuild', options: ['Delete', 'Keep them'] })
438  } catch {
439    return 'Not deleted: nobody to confirm.'
440  }
441  if (answer !== 'Delete') return 'Kept everything.'
442  await awsCli($, c, ['codebuild', 'delete-project', '--name', c.project])
443  await awsCli($, c, ['logs', 'delete-log-group', '--log-group-name', `/aws/codebuild/${c.project}`])
444  await awsCli($, c, ['s3', 'rm', `s3://${c.bucket}/sources/${c.project}/`, '--recursive', '--only-show-errors'], 300_000)
445  if (isAll) {
446    await awsCli($, c, ['iam', 'delete-role-policy', '--role-name', ROLE, '--policy-name', 'claude-codebuild-run'])
447    await awsCli($, c, ['iam', 'delete-role', '--role-name', ROLE])
448    await awsCli($, c, ['s3api', 'delete-bucket', '--bucket', c.bucket])
449  }
450  await $.store.delete(`repo:${repo}`)
451  return `Deleted: ${what.join(', ')}.`
452}
453
454/** The model's tool: confirm, set up if needed, build, wait, and report. */
455async function buildForClaude($: EngineInterface, input: { repo?: unknown; profile?: unknown; wait?: unknown }, signal: AbortSignal): Promise<string> {
456  const repo = await absoluteRepo($, typeof input.repo === 'string' ? input.repo : '')
457  const saved = (await $.store.get(`repo:${repo}`)) as Config | undefined
458  const profile = typeof input.profile === 'string' && input.profile !== '' ? input.profile : saved?.profile ?? settings.profile
459  if (profile === '' && saved === undefined) {
460    return `CodeBuild isn't set up for ${repo} yet. Ask the user to run /codebuild once to choose an AWS profile.`
461  }
462  const view = await read($, buildAtom)
463  if (view !== null && !isDone(view)) return `Build #${view.number} is still running. Wait for it, or ask the user to stop it with /codebuild stop.`
464
465  if (!isClaudeAllowed) {
466    const creates = saved === undefined ? ' First run: creates a private S3 bucket, an IAM role and a CodeBuild project.' : ''
467    let answer = "Don't build"
468    try {
469      answer = await $.ui.ask(`Claude wants to build ${repo} on AWS CodeBuild with profile "${profile}" (~$${PRICE_PER_MINUTE[settings.computeType] ?? 0.005}/min).${creates}`, { header: 'codebuild', options: ['Build', 'Allow this session', "Don't build"] })
470    } catch {
471      return 'The build needs the user\'s OK and nobody could be asked (headless run).'
472    }
473    if (answer === "Don't build") return 'The user declined the CodeBuild build.'
474    if (answer === 'Allow this session') isClaudeAllowed = true
475  }
476
477  const { plan } = await inspectRepo($, repo)
478  const c = await ensureSetup($, repo, profile, plan)
479  await $.ui.open({ id: PANE, title: 'CodeBuild' })
480  const started = await launch($, c, 'claude')
481  if (input.wait === false) {
482    followBuild($)
483    return `Started CodeBuild build #${started.number}: ${started.consoleUrl}`
484  }
485  // Wait through $ calls (a process sleep), so the hook's own time stays tiny.
486  for (let i = 0; i < 450; i++) {
487    if (signal.aborted) {
488      followBuild($)
489      return `Stopped waiting; build #${started.number} keeps running and the pane follows it.`
490    }
491    await $.process.run(['sleep', '4'], { timeoutMs: 10_000 })
492    const now = await refreshBuild($)
493    if (now !== null && isDone(now)) {
494      await finishBuild($, now)
495      return reportForClaude(now, await read($, logAtom), settings.computeType)
496    }
497  }
498  followBuild($)
499  return `Build #${started.number} is still running after 30 minutes; the pane keeps following it.`
500}
501
502export const register: Register = (on, options) => {
503  settings = {
504    profile: String(options.profile ?? ''),
505    region: String(options.region ?? ''),
506    computeType: String(options.computeType ?? 'BUILD_GENERAL1_SMALL'),
507    image: String(options.image ?? 'aws/codebuild/amazonlinux-x86_64-standard:5.0'),
508  }
509
510  on('session.start', async ($, e, next) => {
511    await $.command.register({
512      name: 'codebuild',
513      description: 'Build this repo on AWS CodeBuild: /codebuild [setup|login|profile <name>|fix|stop|status|logs|buildspec [save]|teardown [all]|<path>]',
514      argumentHint: '[setup | login | profile <name> | fix | stop | status | logs | buildspec | teardown | <path>]',
515    })
516    await $.tool.register({
517      name: 'build',
518      description:
519        'Build and test a repository on AWS CodeBuild (a clean Linux machine) and return the outcome and the relevant log lines. ' +
520        'Uploads the working tree, uncommitted changes included. Use when the user asks to build or test on CodeBuild, in CI, or in the cloud, ' +
521        'or to verify a fix there. The user confirms before it runs. After a failure, fix the cause and call it again.',
522      inputSchema: {
523        type: 'object',
524        properties: {
525          repo: { type: 'string', description: 'Folder to build; default: the project root' },
526          profile: { type: 'string', description: 'AWS profile; default: the one this repo was set up with' },
527          wait: { type: 'boolean', description: 'Wait for the result (default true)' },
528        },
529      },
530    })
531    // A reload mid-build: keep following it.
532    const saved = (await $.store.get('active')) as { config: Config; startedBy: 'you' | 'claude' } | undefined
533    const view = await read($, buildAtom)
534    if (saved !== undefined && view !== null && !isDone(view)) {
535      const { plan, hasOwnBuildspec } = await inspectRepo($, saved.config.repo).catch(() => ({ plan: planBuild({ names: [] }), hasOwnBuildspec: false }))
536      active = { ...saved, plan, hasOwnBuildspec }
537      followBuild($)
538    }
539    return next(e)
540  })
541
542  on('command.run', { command: 'codebuild' }, async ($, e) => {
543    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
544    try {
545      if (verb === 'stop') return { text: await stopBuild($) }
546      if (verb === 'login') {
547        const repo = await absoluteRepo($, '')
548        if ((await read($, wizardAtom)) === null) await openWizard($, repo)
549        const profile = rest.join(' ').trim()
550        if (await usesSso($, profile || (await read($, wizardAtom))?.profile || '')) return { text: await signIn($, profile) }
551        // Submitting a prompt has to wait until this command returns.
552        $.clock.after(0, () => void signIn($, profile))
553        return { text: 'Asking Claude to get you AWS credentials…' }
554      }
555      if (verb === 'fix') {
556        const view = await read($, buildAtom)
557        if (view === null || !isDone(view) || view.status === 'SUCCEEDED') return { text: 'No failed build to fix.' }
558        // A command can't submit a prompt while it runs (it holds the turn): do it right after.
559        $.clock.after(0, () => void askClaudeToFix($))
560        return { text: `Asking Claude to fix build #${view.number}…` }
561      }
562      if (verb === 'profile') {
563        const name = rest.join(' ').trim()
564        if (name === '') return { text: 'Usage: /codebuild profile <name>. Your profiles: ' + ((await listProfiles($)).join(', ') || 'none found') }
565        const who = await identityLine($, name)
566        if (!who.isOk) return { text: `${who.text}` }
567        const repo = await absoluteRepo($, '')
568        const saved = (await $.store.get(`repo:${repo}`)) as Config | undefined
569        if (saved !== undefined) await $.store.set(`repo:${repo}`, { ...saved, profile: name })
570        await openWizard($, repo)
571        await pickProfile($, name)
572        return { text: `Using profile ${name}: ${who.text}` }
573      }
574      if (verb === 'setup') {
575        await openWizard($, rest.join(' '))
576        return { text: 'Opened CodeBuild setup.' }
577      }
578      if (verb === 'status' || verb === 'logs') {
579        const view = await read($, buildAtom)
580        if (view === null) return { text: 'No build yet. Run /codebuild.' }
581        if (verb === 'status') return { text: `${statusText(view, await $.clock.now())}\n${view.consoleUrl}` }
582        return { text: (await read($, logAtom)).slice(-40).join('\n') || '(no log lines yet)' }
583      }
584      if (verb === 'buildspec') {
585        const repo = await absoluteRepo($, '')
586        if (rest[0] === 'save') return { text: await saveBuildspec($, repo) }
587        const { plan, hasOwnBuildspec } = await inspectRepo($, repo)
588        return { text: hasOwnBuildspec ? 'This repo has its own buildspec.yml; builds use it.' : `Generated for ${plan.stack.join(' + ')} (/codebuild buildspec save writes it):\n\n${plan.yaml}` }
589      }
590      if (verb === 'teardown') return { text: await teardown($, await absoluteRepo($, ''), rest[0] === 'all') }
591
592      // `/codebuild [path]`: build now if this repo is set up, else open the setup screen.
593      const repo = await absoluteRepo($, verb === '' ? '' : [verb, ...rest].join(' '))
594      const saved = (await $.store.get(`repo:${repo}`)) as Config | undefined
595      if (saved === undefined) {
596        // A second /codebuild on the open setup screen means "go".
597        const ready = await read($, wizardAtom)
598        if (ready !== null && ready.repo === repo && ready.isIdentityOk && (await read($, modeAtom)) === 'wizard') {
599          void setupAndBuild($)
600          return { text: `Setting up and building with profile ${ready.profile}. Follow it in the CodeBuild pane.` }
601        }
602        await openWizard($, repo)
603        const w = await read($, wizardAtom)
604        if (w !== null && w.isIdentityOk) {
605          return { text: `Ready with profile ${w.profile} (${w.identity.replace(/^✓ /, '')}).\nRun /codebuild again to set up and build, or switch with /codebuild profile <name>.` }
606        }
607        return { text: `No AWS profile with working credentials found (${w?.others || w?.identity || 'none listed'}).\nRun /codebuild login and it signs you in (aws sso login for SSO profiles, otherwise Claude gets credentials), then /codebuild again.` }
608      }
609      const view = await read($, buildAtom)
610      if (view !== null && !isDone(view)) {
611        await $.ui.open({ id: PANE, title: 'CodeBuild' })
612        return { text: `Build #${view.number} is still running.` }
613      }
614      await $.ui.open({ id: PANE, title: 'CodeBuild' })
615      try {
616        const started = await launch($, saved, 'you')
617        followBuild($)
618        return { text: `Started build #${started.number} of ${repo} on ${saved.project} (${saved.region}).` }
619      } catch (error) {
620        // The remembered profile is gone or signed out: hand back the setup screen instead of a dead end.
621        const why = error instanceof Error ? error.message : String(error)
622        $.ui.status(undefined)
623        await openWizard($, repo)
624        await update($, wizardAtom, w => (w === null ? w : { ...w, message: `✗ ${why}` }))
625        return { text: `✗ ${why}\nPick a working AWS profile in the CodeBuild pane, then press Build.` }
626      }
627    } catch (error) {
628      $.ui.status(undefined)
629      return { text: `✗ ${error instanceof Error ? error.message : String(error)}` }
630    }
631  })
632
633  // After Claude got credentials, check the profiles again so setup moves on by itself.
634  on('turn.complete', async ($, e, next) => {
635    const out = await next(e)
636    if (isAwaitingSignIn && e.agentId === undefined) {
637      isAwaitingSignIn = false
638      $.clock.after(0, () => void recheckProfiles($))
639    }
640    return out
641  })
642
643  on('tool.call', { tool: TOOL }, async ($, e, next) => {
644    try {
645      return { result: await buildForClaude($, e as { repo?: unknown; profile?: unknown; wait?: unknown }, next.signal) }
646    } catch (error) {
647      return { result: `CodeBuild couldn't run: ${error instanceof Error ? error.message : String(error)}` }
648    }
649  })
650
651  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
652    if (e.surface === 'mobile') {
653      const { Text } = $.ui.resolve(e)
654      const view = await read($, buildAtom)
655      return <Text>{view === null ? 'Open CodeBuild from the terminal or desktop app.' : statusText(view, Date.now())}</Text>
656    }
657    const { Box, Button, Text, Select, Input, Markdown, Link } = $.ui.resolve(e)
658    const width = Math.max(30, e.props.bodyColumns - 2)
659    const mode = await read($, modeAtom)
660    const build = await read($, buildAtom)
661    const w = await read($, wizardAtom)
662
663    if (mode === 'wizard' || build === null) {
664      if (w === null) return <Text dimColor>Run /codebuild to set up a build.</Text>
665      return (
666        <Box flexDirection="column">
667          <Text bold>☁️ Build on AWS CodeBuild</Text>
668          {w.profiles.length > 0 ? (
669            <Select key="profile" label="AWS profile" value={w.profile} options={(w.profiles.includes(w.profile) || w.profile === '' ? w.profiles : [w.profile, ...w.profiles]).map(p => ({ value: p, label: p }))} onSelect={value => void pickProfile($, value)} />
670          ) : (
671            <Input key="profile-name" label="AWS profile" value={w.profile} placeholder="default" submitLabel="Check" onSubmit={value => void pickProfile($, value)} />
672          )}
673          <Text color={w.isIdentityOk ? 'green' : 'red'} wrap="wrap">{`  ${w.identity}`}</Text>
674          {w.others !== '' && <Text dimColor wrap="wrap">{`  other profiles: ${w.others} · switch with /codebuild profile <name>`}</Text>}
675          <Input key="repo" label="Repo" value={w.repo} submitLabel="Use" onSubmit={value => void pickRepo($, value)} />
676          <Text dimColor wrap="wrap">{`  ${w.stack} · ${w.hasOwnBuildspec ? 'your buildspec.yml' : 'buildspec generated for you'}`}</Text>
677          <Text dimColor wrap="wrap">
678            {w.isConfigured ? '  Already set up: builds start right away.' : '  First run creates: a private S3 bucket (sources expire in 7 days), one least-privilege IAM role, one CodeBuild project.'}
679          </Text>
680          <Text dimColor>{`  ~$${PRICE_PER_MINUTE[settings.computeType] ?? 0.005}/min · ${settings.computeType.replace('BUILD_GENERAL1_', '').toLowerCase()} Linux machine`}</Text>
681          {w.message !== '' && <Text color={w.message.startsWith('✗') ? 'red' : 'yellow'} wrap="wrap">{w.message}</Text>}
682          {w.showBuildspec && <Markdown key="spec" text={'```yaml\n' + w.buildspec + '```'} />}
683          <Box flexDirection="row" columnGap={1}>
684            {w.isIdentityOk ? (
685              <Button key="go" variant="primary" hotkey="b" label={w.isBusy ? 'Working…' : w.isConfigured ? 'Build' : 'Set up & build'} onPress={() => void setupAndBuild($)} />
686            ) : (
687              <Button key="login" variant="primary" hotkey="l" label={w.isSso ? 'Sign in with SSO' : 'Sign me in'} onPress={() => void signIn($, '')} />
688            )}
689            {!w.hasOwnBuildspec && <Button key="spec-toggle" hotkey="s" label={w.showBuildspec ? 'Hide buildspec' : 'Show buildspec'} onPress={() => void update($, wizardAtom, x => (x === null ? x : { ...x, showBuildspec: !x.showBuildspec }))} />}
690            <Button key="cancel" hotkey="x" label="Close" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
691          </Box>
692          <Text dimColor wrap="wrap">
693            {w.isIdentityOk
694              ? '  or just run /codebuild again · Esc returns to the prompt'
695              : w.isSso
696                ? '  or run /codebuild login: it runs aws sso login and opens your browser.'
697                : '  or run /codebuild login: Claude gets you credentials its own way, then setup checks again.'}
698          </Text>
699        </Box>
700      )
701    }
702
703    const log = await read($, logAtom)
704    const isRunning = !isDone(build)
705    const color = build.status === 'SUCCEEDED' ? 'green' : isRunning ? 'yellow' : 'red'
706    const glyph = (status: string) => (status === 'SUCCEEDED' ? '✓' : status === 'IN_PROGRESS' ? (isRunning ? '◐' : '✓') : '✗')
707    const phases = shownPhases(build).map(p => `${glyph(p.status)} ${p.name.toLowerCase().replace(/_/g, ' ')}${p.seconds ? ` ${p.seconds}s` : ''}`).join('  ')
708    const rows = e.props.placement === 'dock' ? Math.max(4, e.props.scroll.bodyRows - 9) : 14
709    return (
710      <Box flexDirection="column">
711        <Text bold color={color}>{statusText(build, Date.now())}</Text>
712        <Text dimColor wrap="truncate-end">{`${build.project} · started by ${build.startedBy}${build.failure ? ` · ${build.failure}` : ''}`}</Text>
713        <Text wrap="wrap">{phases || 'submitted'}</Text>
714        <Box flexDirection="column">
715          {(log.length === 0 ? ['(waiting for logs…)'] : usefulLog(build, log).slice(-rows)).map((line, i) => (
716            <Text key={`l${i}`} dimColor wrap="truncate-end">{line.slice(0, width)}</Text>
717          ))}
718        </Box>
719        <Box flexDirection="row" columnGap={1}>
720          {isRunning && <Button key="stop" hotkey="s" label="Stop" onPress={() => void stopBuild($)} />}
721          {!isRunning && build.status !== 'SUCCEEDED' && <Button key="fix" variant="primary" hotkey="f" label="Ask Claude to fix" onPress={() => void askClaudeToFix($)} />}
722          {!isRunning && <Button key="again" hotkey="b" label="Build again" onPress={() => void (active ? launch($, active.config, 'you').then(() => followBuild($)) : Promise.resolve())} />}
723          <Button key="settings" hotkey="p" label="Settings" onPress={() => void openWizard($, active?.config.repo ?? '')} />
724          <Button key="close" hotkey="x" label="Close" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
725        </Box>
726        <Link href={build.consoleUrl} label="Open in the AWS console" />
727        <Text dimColor>{isRunning ? '/codebuild stop' : build.status === 'SUCCEEDED' ? '/codebuild builds again' : '/codebuild fix asks Claude · /codebuild builds again'}</Text>
728      </Box>
729    )
730  })
731}
732
hooks/aws.ts 209 lines
1// Names, policies, parsing and wording for the codebuild mod. Pure: no `$`.
2
3import type { BuildView } from '../types'
4
5export type Target = { profile: string; region: string; account: string }
6
7export type Config = Target & { repo: string; project: string; bucket: string; role: string }
8
9
10export const ROLE = 'claude-codebuild-service-role'
11export const TAG = { Key: 'created-by', Value: 'claude-codebuild-mod' }
12
13// On-demand Linux prices per minute, us-east-1. An estimate, not a bill.
14export const PRICE_PER_MINUTE: Record<string, number> = {
15  BUILD_GENERAL1_SMALL: 0.005,
16  BUILD_GENERAL1_MEDIUM: 0.01,
17  BUILD_GENERAL1_LARGE: 0.02,
18}
19
20export const bucketName = (account: string, region: string) => `claude-codebuild-${account}-${region}`
21
22/** `claude-<repo folder>-<6 hex of the path>`: stable per repo, unique across same-named folders. */
23export function projectName(repo: string): string {
24  const base = (repo.replace(/\/+$/, '').split('/').pop() || 'repo').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'repo'
25  let hash = 2166136261
26  for (const ch of repo) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619) >>> 0
27  return `claude-${base}-${hash.toString(16).padStart(8, '0').slice(0, 6)}`
28}
29
30export const trustPolicy = (account: string) => ({
31  Version: '2012-10-17',
32  Statement: [{
33    Effect: 'Allow',
34    Principal: { Service: 'codebuild.amazonaws.com' },
35    Action: 'sts:AssumeRole',
36    Condition: { StringEquals: { 'aws:SourceAccount': account } },
37  }],
38})
39
40/** Logs for claude-* projects, and read access to their uploaded sources: nothing else. */
41export const rolePolicy = (account: string) => ({
42  Version: '2012-10-17',
43  Statement: [
44    {
45      Effect: 'Allow',
46      Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
47      Resource: [`arn:aws:logs:*:${account}:log-group:/aws/codebuild/claude-*`, `arn:aws:logs:*:${account}:log-group:/aws/codebuild/claude-*:*`],
48    },
49    {
50      Effect: 'Allow',
51      Action: ['s3:GetObject', 's3:GetObjectVersion'],
52      Resource: [`arn:aws:s3:::claude-codebuild-${account}-*/sources/*`],
53    },
54    {
55      Effect: 'Allow',
56      Action: ['s3:GetBucketLocation', 's3:GetBucketAcl'],
57      Resource: [`arn:aws:s3:::claude-codebuild-${account}-*`],
58    },
59  ],
60})
61
62export const lifecycle = {
63  Rules: [{ ID: 'expire-claude-sources', Status: 'Enabled', Filter: { Prefix: 'sources/' }, Expiration: { Days: 7 } }],
64}
65
66export function projectInput(c: Config, options: { computeType: string; image: string; privileged: boolean }) {
67  return {
68    name: c.project,
69    description: `Builds ${c.repo} from Claude Code (codebuild mod)`,
70    source: { type: 'NO_SOURCE', buildspec: 'version: 0.2\nphases:\n  build:\n    commands:\n      - echo "Started without a source: run it from Claude Code with /codebuild."\n' },
71    artifacts: { type: 'NO_ARTIFACTS' },
72    environment: { type: 'LINUX_CONTAINER', image: options.image, computeType: options.computeType, privilegedMode: options.privileged },
73    serviceRole: `arn:aws:iam::${c.account}:role/${ROLE}`,
74    timeoutInMinutes: 30,
75    logsConfig: { cloudWatchLogs: { status: 'ENABLED', groupName: `/aws/codebuild/${c.project}` } },
76    tags: [{ key: TAG.Key, value: TAG.Value }],
77  }
78}
79
80export const consoleUrl = (c: Config, buildId: string) =>
81  `https://${c.region}.console.aws.amazon.com/codesuite/codebuild/${c.account}/projects/${c.project}/build/${encodeURIComponent(buildId)}/?region=${c.region}`
82
83type RawPhase = { phaseType?: string; phaseStatus?: string; durationInSeconds?: number; contexts?: { message?: string }[] }
84type RawBuild = {
85  id?: string; buildNumber?: number; projectName?: string; buildStatus?: string; currentPhase?: string
86  startTime?: string | number; endTime?: string | number; phases?: RawPhase[]
87  logs?: { groupName?: string; streamName?: string }
88}
89
90const time = (t: string | number | undefined) => (t === undefined ? undefined : typeof t === 'number' ? t * 1000 : Date.parse(t))
91
92/** One build from `batch-get-builds`, as the pane draws it. */
93export function parseBuild(raw: RawBuild, c: Config, startedBy: 'you' | 'claude'): BuildView {
94  const phases = (raw.phases ?? []).map(p => ({ name: p.phaseType ?? '?', status: p.phaseStatus ?? 'IN_PROGRESS', seconds: p.durationInSeconds }))
95  const failed = (raw.phases ?? []).find(p => p.phaseStatus !== undefined && !['SUCCEEDED', 'IN_PROGRESS'].includes(p.phaseStatus))
96  const id = raw.id ?? ''
97  return {
98    id,
99    number: raw.buildNumber ?? 0,
100    project: raw.projectName ?? c.project,
101    status: raw.buildStatus ?? 'IN_PROGRESS',
102    phase: raw.currentPhase ?? 'SUBMITTED',
103    phases,
104    startedAt: time(raw.startTime) ?? 0,
105    endedAt: time(raw.endTime),
106    logGroup: raw.logs?.groupName,
107    logStream: raw.logs?.streamName,
108    consoleUrl: consoleUrl(c, id),
109    startedBy,
110    failure: failed === undefined ? undefined : `${failed.phaseType}: ${failed.contexts?.map(x => x.message).filter(Boolean).join('; ') || failed.phaseStatus}`,
111  }
112}
113
114export const isDone = (b: BuildView) => b.status !== 'IN_PROGRESS'
115
116// Phases worth a glance; the rest (queued, finalizing...) show only when they fail.
117const MAIN_PHASES = ['PROVISIONING', 'DOWNLOAD_SOURCE', 'INSTALL', 'PRE_BUILD', 'BUILD', 'POST_BUILD']
118
119/** The phases the pane lists: the main ones, plus any that failed. */
120export const shownPhases = (b: BuildView) =>
121  b.phases.filter(p => MAIN_PHASES.includes(p.name) || (p.status !== 'SUCCEEDED' && p.status !== 'IN_PROGRESS' && p.name !== 'COMPLETED'))
122
123/**
124 * The log lines that explain the outcome: for a failed build, everything up
125 * to CodeBuild's "Command did not exit successfully" line, not the bookkeeping after it.
126 */
127export function usefulLog(b: BuildView, log: readonly string[]): readonly string[] {
128  const lines = log.filter(line => !isBookkeeping(line))
129  if (b.status === 'SUCCEEDED' || b.status === 'IN_PROGRESS') return lines
130  const at = lines.findIndex(line => /Command did not exit successfully|Phase complete: \w+ State: FAILED/.test(line))
131  return at < 0 ? lines : lines.slice(0, at + 1)
132}
133
134// CodeBuild's own housekeeping lines: kept in the console, left out of the pane and of Claude's report.
135const BOOKKEEPING = /^\[Container\] \S+ \S+ (Phase context status code|Expanding|Assembling file list|Set report auto-discover|No matching auto-discover|Report auto-discover|Skip cleanup|Selecting|Processing environment|Moving to directory|Registering with agent|Waiting for agent|Phases found|Cache is not defined|Successfully updated|Waiting for DOWNLOAD_SOURCE|Entering phase|Phase complete: (?!BUILD State: FAILED))/
136
137export const isBookkeeping = (line: string) => BOOKKEEPING.test(line)
138
139export function duration(ms: number): string {
140  const s = Math.max(0, Math.round(ms / 1000))
141  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
142}
143
144export const costOf = (ms: number, computeType: string) => (Math.max(1, Math.ceil(ms / 60_000)) * (PRICE_PER_MINUTE[computeType] ?? 0.005))
145
146/** The status line: `☁️ #14 · BUILD · 1m 20s`, or the outcome. */
147export function statusText(b: BuildView, now: number): string {
148  const took = duration((b.endedAt ?? now) - (b.startedAt || now))
149  if (b.status === 'SUCCEEDED') return `☁️ ✅ #${b.number} passed · ${took}`
150  if (b.status === 'IN_PROGRESS') return `☁️ #${b.number} · ${b.phase} · ${took}`
151  return `☁️ ❌ #${b.number} ${b.status.toLowerCase().replace('_', ' ')}${b.failure ? ` in ${b.failure.split(':')[0]}` : ''} · ${took}`
152}
153
154/** Files that never leave the machine, whatever .gitignore says. */
155export function isSecretPath(path: string): boolean {
156  const name = path.split('/').pop() ?? ''
157  if (/^\.env(\..+)?$/.test(name)) return !/\.(example|sample|template|dist)$/.test(name)
158  return /\.(pem|key|p12|pfx)$/.test(name) || /^id_(rsa|ed25519|ecdsa|dsa)/.test(name) || /^\.?(npmrc|netrc|pypirc)$/.test(name) || name === 'credentials'
159}
160
161/** Turns an AWS CLI failure into what the person should do. */
162export function explainAwsError(stderr: string, profile: string): string {
163  const text = stderr.trim()
164  if (/could not be found/.test(text) && /profile/i.test(text)) return `AWS profile "${profile}" doesn't exist. Pick another, or create it with: aws configure --profile ${profile}`
165  if (/Unable to locate credentials|NoCredentialProviders/.test(text)) return `Profile "${profile}" has no credentials. Run: aws configure --profile ${profile}  (or aws sso login --profile ${profile})`
166  if (/Token has expired|SSO session|token.*expired|ExpiredToken|refresh failed|UnauthorizedSSOToken/i.test(text)) return `Your AWS session for "${profile}" expired. Run: aws sso login --profile ${profile}`
167  const action = text.match(/not authorized to perform: ([\w:*-]+)/)?.[1]
168  if (action !== undefined) return `Your AWS identity isn't allowed ${action}. Ask an admin to grant it, or use a profile with more access.`
169  if (/AccessDenied/.test(text)) return `AWS refused the request (AccessDenied): ${text.split('\n')[0]?.slice(0, 200)}`
170  return text.split('\n').filter(Boolean).slice(-2).join(' ').slice(0, 300) || 'the AWS CLI failed without saying why'
171}
172
173/** What Claude reads after a build: outcome, link, and the log lines that matter. */
174export function reportForClaude(b: BuildView, log: readonly string[], computeType: string): string {
175  const took = duration((b.endedAt ?? b.startedAt) - b.startedAt)
176  const head = `CodeBuild build #${b.number} (${b.project}) ${b.status} after ${took}, about $${costOf((b.endedAt ?? b.startedAt) - b.startedAt, computeType).toFixed(3)}.`
177  const lines = [head, `Console: ${b.consoleUrl}`]
178  if (b.failure !== undefined) lines.push(`Failed phase: ${b.failure}`)
179  const tail = usefulLog(b, log).slice(b.status === 'SUCCEEDED' ? -15 : -80)
180  if (tail.length > 0) lines.push('', `Last ${tail.length} log lines:`, '```', ...tail, '```')
181  return lines.join('\n')
182}
183
184/** What the person's AWS identity needs, for a sign-in request: every action, scoped to what the mod touches. */
185export function requiredPermissions(account: string): string {
186  const where = account === '' ? 'your account' : `account ${account}`
187  const own = account === '' ? '<account>' : account
188  return [
189    `In ${where}, any region:`,
190    '- sts:GetCallerIdentity',
191    `- S3 on buckets claude-codebuild-${own}-*: ListBucket, CreateBucket, PutBucketPublicAccessBlock, PutLifecycleConfiguration, PutBucketTagging, GetBucketLocation, PutObject, GetObject, DeleteObject (under sources/claude-*), DeleteBucket`,
192    `- IAM on role ${ROLE}: GetRole, CreateRole, TagRole, PutRolePolicy, PassRole (to codebuild.amazonaws.com), DeleteRolePolicy, DeleteRole`,
193    '- CodeBuild on projects claude-*: BatchGetProjects, CreateProject, StartBuild, BatchGetBuilds, StopBuild, DeleteProject',
194    '- CloudWatch Logs on log groups /aws/codebuild/claude-*: GetLogEvents, DescribeLogStreams, DeleteLogGroup',
195  ].join('\n')
196}
197
198/** The request Claude gets when nobody is signed in: sign-in is the environment's business, so Claude picks the way. */
199export function signInRequest(profiles: string, permissions: string): string {
200  return [
201    'I want to build this repo on AWS CodeBuild with the codebuild mod, but none of my AWS profiles has working credentials' + (profiles === '' ? '.' : ` (${profiles}).`),
202    'Get me working credentials in an AWS profile, using whatever this environment provides for AWS sign-in (an AWS credentials tool if you have one, else tell me the exact command to run, such as aws sso login --profile <name> or aws configure).',
203    'If you need an account ID, a role or a profile name and can\'t find them from my configured profiles, ask me.',
204    'The profile needs these permissions:',
205    permissions,
206    'Don\'t start a build yourself; the mod checks the profiles again when you\'re done.',
207  ].join('\n')
208}
209
hooks/buildspec.ts 150 lines
1// Generates a CodeBuild buildspec from what a repo's top folder holds. Pure.
2
3export type ProjectFiles = {
4  /** Names of the files and folders at the build root. */
5  names: readonly string[]
6  /** package.json, parsed, when there is one. */
7  packageJson?: { scripts?: Record<string, string>; engines?: { node?: string }; packageManager?: string }
8  /** The contents of .nvmrc / .node-version, when present. */
9  nodeVersion?: string
10  /** The contents of .python-version, when present. */
11  pythonVersion?: string
12  /** The Makefile, when present. */
13  makefile?: string
14}
15
16export type Plan = {
17  /** What was detected, for people: `node (npm)`, `python`, `go`. */
18  stack: string[]
19  /** The buildspec, as YAML. */
20  yaml: string
21  /** Whether the build needs Docker (privileged mode). */
22  needsDocker: boolean
23}
24
25// Runtimes the CodeBuild Amazon Linux 2023 standard:5.0 image can switch to.
26const NODE = ['18', '20', '22']
27const PYTHON = ['3.11', '3.12', '3.13']
28
29const has = (files: ProjectFiles, name: string) => files.names.includes(name)
30const isRealScript = (script: string | undefined) => script !== undefined && !/no test specified/.test(script)
31
32function major(version: string | undefined, known: readonly string[]): string | undefined {
33  const found = String(version ?? '').match(/(\d+)(?:\.(\d+))?/)
34  if (found === null) return undefined
35  const twoPart = `${found[1]}.${found[2] ?? ''}`
36  return known.includes(found[1]!) ? found[1] : known.includes(twoPart) ? twoPart : undefined
37}
38
39export function planBuild(files: ProjectFiles): Plan {
40  const runtimes: Record<string, string> = {}
41  const install: string[] = []
42  const build: string[] = []
43  const stack: string[] = []
44  let needsDocker = false
45
46  if (has(files, 'package.json')) {
47    const pkg = files.packageJson ?? {}
48    const scripts = pkg.scripts ?? {}
49    const pm = has(files, 'pnpm-lock.yaml') || String(pkg.packageManager).startsWith('pnpm') ? 'pnpm'
50      : has(files, 'yarn.lock') || String(pkg.packageManager).startsWith('yarn') ? 'yarn'
51      : 'npm'
52    stack.push(`node (${pm})`)
53    const node = major(files.nodeVersion ?? pkg.engines?.node, NODE)
54    if (node !== undefined) runtimes.nodejs = node
55    if (pm === 'pnpm') install.push('corepack enable', 'pnpm install --frozen-lockfile')
56    else if (pm === 'yarn') install.push('corepack enable', has(files, '.yarnrc.yml') ? 'yarn install --immutable' : 'yarn install --frozen-lockfile')
57    else install.push(has(files, 'package-lock.json') ? 'npm ci' : 'npm install')
58    const run = pm === 'npm' ? 'npm run' : pm
59    if (scripts.lint !== undefined) build.push(`${run} lint`)
60    if (scripts.build !== undefined) build.push(`${run} build`)
61    if (isRealScript(scripts.test)) build.push(pm === 'npm' ? 'npm test' : `${pm} test`)
62  }
63
64  const isPython = has(files, 'pyproject.toml') || has(files, 'requirements.txt') || has(files, 'setup.py')
65  if (isPython) {
66    stack.push('python')
67    const python = major(files.pythonVersion, PYTHON)
68    if (python !== undefined) runtimes.python = python
69    install.push('python -m pip install --upgrade pip')
70    if (has(files, 'requirements.txt')) install.push('pip install -r requirements.txt')
71    if (has(files, 'requirements-dev.txt')) install.push('pip install -r requirements-dev.txt')
72    if (has(files, 'pyproject.toml') || has(files, 'setup.py')) install.push('pip install -e .')
73    if (has(files, 'tests') || has(files, 'test') || has(files, 'pytest.ini') || has(files, 'conftest.py')) {
74      install.push('pip install pytest')
75      build.push('python -m pytest')
76    }
77  }
78
79  if (has(files, 'go.mod')) {
80    stack.push('go')
81    build.push('go build ./...', 'go test ./...')
82  }
83
84  if (has(files, 'Cargo.toml')) {
85    stack.push('rust')
86    install.push('curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal', '. "$HOME/.cargo/env"')
87    build.push('cargo build --locked', 'cargo test --locked')
88  }
89
90  if (has(files, 'pom.xml')) {
91    stack.push('java (maven)')
92    runtimes.java = 'corretto21'
93    build.push('mvn -B -ntp verify')
94  } else if (has(files, 'build.gradle') || has(files, 'build.gradle.kts')) {
95    stack.push('java (gradle)')
96    runtimes.java = 'corretto21'
97    build.push(has(files, 'gradlew') ? 'chmod +x gradlew && ./gradlew build' : 'gradle build')
98  }
99
100  if (files.names.some(n => n.endsWith('.sln') || n.endsWith('.csproj'))) {
101    stack.push('.net')
102    build.push('dotnet build', 'dotnet test')
103  }
104
105  if (has(files, 'Gemfile')) {
106    stack.push('ruby')
107    install.push('bundle install')
108    build.push(has(files, 'Rakefile') ? 'bundle exec rake' : 'bundle exec rspec')
109  }
110
111  if (build.length === 0 && has(files, 'Makefile')) {
112    stack.push('make')
113    build.push('make')
114    if (/^test\s*:/m.test(files.makefile ?? '')) build.push('make test')
115  }
116
117  if (build.length === 0 && has(files, 'Dockerfile')) {
118    stack.push('docker')
119    needsDocker = true
120    build.push('docker build -t app .')
121  }
122
123  if (build.length === 0) {
124    stack.push('unknown')
125    build.push('echo "No build detected: add a buildspec.yml (/codebuild buildspec save writes a starter)."', 'ls -la')
126  }
127
128  const lines = ['version: 0.2', '# Generated by the codebuild mod for Claude Code. Save it as buildspec.yml to customise.', 'phases:']
129  if (Object.keys(runtimes).length > 0 || install.length > 0) {
130    lines.push('  install:')
131    if (Object.keys(runtimes).length > 0) {
132      lines.push('    runtime-versions:')
133      for (const [name, version] of Object.entries(runtimes)) lines.push(`      ${name}: ${version}`)
134    }
135    if (install.length > 0) {
136      lines.push('    commands:')
137      for (const command of install) lines.push(`      - ${yamlString(command)}`)
138    }
139  }
140  lines.push('  build:', '    commands:')
141  for (const command of build) lines.push(`      - ${yamlString(command)}`)
142
143  return { stack, yaml: lines.join('\n') + '\n', needsDocker }
144}
145
146/** A command as a YAML scalar: quoted when YAML would read it as something else. */
147export function yamlString(text: string): string {
148  return /^[\w./-][^:#{}[\],&*!|>'"%@`]*$/.test(text) && !/\s$/.test(text) ? text : JSON.stringify(text)
149}
150
types/index.d.ts 44 lines
1export type Phase = { name: string; status: string; seconds?: number }
2
3export type BuildView = {
4  id: string
5  number: number
6  project: string
7  status: string
8  phase: string
9  phases: Phase[]
10  startedAt: number
11  endedAt?: number
12  logGroup?: string
13  logStream?: string
14  consoleUrl: string
15  startedBy: 'you' | 'claude'
16  failure?: string
17}
18
19export type Wizard = {
20  profiles: string[]
21  profile: string
22  repo: string
23  /** `✓ account 1234…5678 · us-east-1 · role/Dev`, or what's wrong. */
24  identity: string
25  isIdentityOk: boolean
26  /** The other profiles, checked: `simian ✗ no credentials`. */
27  others: string
28  /** The chosen profile signs in with `aws sso login`. */
29  isSso: boolean
30  stack: string
31  buildspec: string
32  hasOwnBuildspec: boolean
33  isConfigured: boolean
34  showBuildspec: boolean
35  message: string
36  isBusy: boolean
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    codebuild: { wizard: Wizard | null; build: BuildView | null; log: string[]; mode: 'wizard' | 'build' }
42  }
43}
44