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…

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.
/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:claude-* build logs and read the uploaded sourcesAfter that, /codebuild builds straight away.
.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.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.☁️ #3 · BUILD · 1m 20s, and a toast says when it's done and what it cost.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./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.aws sso login --profile … command to run. A missing permission names the IAM action. A missing profile or CLI says how to fix it./codebuild teardown deletes this repo's project, log group and uploaded sources after you confirm. teardown all also deletes the shared role and bucket./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.
| Command | What it does |
|---|---|
/codebuild | Build 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 fix | Hand the failed build's log to Claude to fix |
/codebuild setup | Change the profile or repo |
/codebuild status · logs · stop | The running build |
/codebuild buildspec · buildspec save | See or save the generated buildspec |
/codebuild teardown · teardown all | Delete what it created |
| Option | Default | What it does |
|---|---|---|
profile | empty | Profile for repos not set up yet. Empty: AWS_PROFILE, else default |
region | empty | Empty: the profile's region, else us-east-1 |
computeType | BUILD_GENERAL1_SMALL | SMALL, MEDIUM or LARGE |
image | aws/codebuild/amazonlinux-x86_64-standard:5.0 | Image for new projects |
| When | Actions |
|---|---|
| Every build | sts:GetCallerIdentity, s3:PutObject on the bucket, codebuild:StartBuild, BatchGetBuilds, StopBuild, logs:GetLogEvents |
| First build in an account or region | s3:CreateBucket, PutBucketPublicAccessBlock, PutLifecycleConfiguration, PutBucketTagging, iam:CreateRole, PutRolePolicy, iam:PassRole (its own role), codebuild:CreateProject, BatchGetProjects |
| Event | Why |
|---|---|
command.run | /codebuild and its subcommands |
tool.call on mcp__codebuild__build | Claude's tool: confirm, build, wait, report |
ui.render on Pane | The setup screen and the live build |
$.process.run | aws and git |
$.clock.every | Follows a build you started (every 4 seconds) |
$.state, $.store | The live build, and each repo's setup |
claude plugin test mods/cloud/codebuild # 26 tests, with the AWS CLI and git stubbed
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.
hooks/register.tsx 732 lines1import { 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}
732hooks/aws.ts 209 lines1// 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}
209hooks/buildspec.ts 150 lines1// 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}
150types/index.d.ts 44 lines1export 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