The /release command for an Amanorsac app repo: checks the GitHub build, fetches the installers, records the app, runs /brag, writes handoff.md, uploads the…

hooks/register.ts 218 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3
4/* An app repo's end of the Release Standard (docs/release-standard.md in
5 the website repo). /release <version> does the mechanical steps itself:
6 it reads the version, checks the GitHub build, fetches its installers
7 and sums them, lays the package folder out, and after the model has
8 recorded the app, run /brag and written the notes, /release upload
9 lists the package back from the bucket and compares bytes before the
10 hand-off message. The steps that need judgement (recording, the brag,
11 the notes) are handed to the model as instructions, with the standard's
12 words. It never writes under <app>/ in the bucket: that is the website's. */
13
14type ReleaseRun = { version: string; dir: string; stage: string }
15const run = atom({ plugin: 'app-release', key: 'run' } as const, null as ReleaseRun | null)
16
17let app = ''
18let root = 'r2:amanorsac-downloads/releases'
19let website = ''
20
21async function sh($: EngineInterface, argv: readonly string[], timeoutMs = 120_000) {
22 const ran = await $.process.run(argv, { timeoutMs })
23 return { ok: ran.exitCode === 0, out: ran.stdout.trim(), err: ran.stderr.trim() }
24}
25
26/* The version the code says it is, from the first of these that exists. */
27async function codeVersion($: EngineInterface): Promise<string | null> {
28 for (const f of ['package.json', 'app.json', 'pubspec.yaml', 'VERSION', 'version.txt']) {
29 try {
30 const text = await $.fs.read(f)
31 const m = text.match(/"version"\s*:\s*"([^"]+)"/) || text.match(/^version:\s*([\w.+-]+)/m) || text.match(/^\s*([\d]+\.[\d]+\.[\d]+)\s*$/m)
32 if (m) return m[1].split('+')[0]
33 } catch {}
34 }
35 const tag = await sh($, ['git', 'describe', '--tags', '--abbrev=0'])
36 return tag.ok ? tag.out.replace(/^v/, '') : null
37}
38
39async function ghRelease($: EngineInterface, version: string) {
40 const r = await sh($, ['gh', 'api', `repos/{owner}/{repo}/releases/tags/v${version}`], 60_000)
41 if (!r.ok) {
42 const r2 = await sh($, ['gh', 'api', `repos/{owner}/{repo}/releases/tags/${version}`], 60_000)
43 if (!r2.ok) return null
44 return JSON.parse(r2.out) as { assets: { name: string; size: number; browser_download_url: string }[] }
45 }
46 return JSON.parse(r.out) as { assets: { name: string; size: number; browser_download_url: string }[] }
47}
48
49function platformOf(name: string): string | null {
50 const n = name.toLowerCase()
51 if (/\.(exe|msi)$/.test(n) || /windows|win64|win32/.test(n)) return 'windows'
52 if (/arm64|aarch64|apple-?silicon/.test(n) && /\.(dmg|pkg|zip)$/.test(n)) return 'mac-arm64'
53 if (/x64|x86_64|intel/.test(n) && /\.(dmg|pkg|zip)$/.test(n)) return 'mac-x64'
54 if (/\.(dmg|pkg)$/.test(n) || /mac|darwin|osx/.test(n)) return 'mac'
55 if (/\.(apk|aab)$/.test(n) || /android/.test(n)) return 'android'
56 if (/\.ipa$/.test(n) || /\bios\b/.test(n)) return 'ios'
57 return null
58}
59
60export const register: Register = (on, options) => {
61 app = String(options.app ?? '')
62 const remote = String(options.remote ?? 'r2')
63 const bucket = String(options.bucket ?? 'amanorsac-downloads')
64 root = `${remote}:${bucket}/releases`
65 website = String(options.website_session ?? '')
66
67 on('session.start', async ($, e, next) => {
68 await $.command.register({
69 name: 'release',
70 description: 'Release this app to the website: installers, recording, brag video, notes, R2 upload, hand-off',
71 argumentHint: '<version> | upload | status',
72 })
73 return next(e)
74 })
75
76 on('command.run', { command: 'release' }, async ($, e) => {
77 const [sub, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
78 const state = await read($, run)
79
80 if (!app) return { text: 'app-release: set the "Catalog id" option for this plugin first (the app\'s id in the website\'s catalog.json).' }
81
82 if (!sub || sub === 'status') {
83 return { text: state ? `Release ${state.version} in progress, stage: ${state.stage}. Package folder: ${state.dir}/` : 'No release in progress. /release <version> starts one.' }
84 }
85
86 /* ---- /release upload: list back, compare, hand off ---- */
87 if (sub === 'upload') {
88 if (!state) return { text: 'No release in progress. /release <version> first.' }
89 const dir = state.dir
90 const need = ['handoff.md', 'brag/brag.mp4', 'brag/brag.jpg', 'brag/share-copy.txt']
91 const missing: string[] = []
92 for (const f of need) { try { await $.fs.stat(`${dir}/${f}`) } catch { missing.push(f) } }
93 const shots = (await $.fs.list(`${dir}/screenshots`).catch(() => [])).filter(x => /^\d\d-.+\.png$/.test(x.name))
94 if (!shots.length) missing.push('screenshots/NN-<what>.png')
95 const inst = (await $.fs.list(`${dir}/installers`).catch(() => []))
96 if (!inst.length) missing.push('installers/')
97 if (missing.length) return { text: `The package is not complete (release-standard §2). Missing:\n` + missing.map(m => ` ${m}`).join('\n') }
98
99 const dest = `${root}/${app}/${state.version}`
100 const cwd = await $.session.cwd()
101 const up = await sh($, ['rclone', 'copy', `${cwd}/${dir}`, dest, '--transfers', '4'], 600_000)
102 if (!up.ok) return { text: `Upload failed: ${up.err.slice(0, 400)}` }
103 const back = await sh($, ['rclone', 'lsjson', '-R', dest], 120_000)
104 if (!back.ok) return { text: `Uploaded, but listing ${dest} back failed: ${back.err.slice(0, 300)}` }
105 const remoteFiles = new Map((JSON.parse(back.out) as { Path: string; Size: number; IsDir: boolean }[]).filter(f => !f.IsDir).map(f => [f.Path, f.Size]))
106 const local = await sh($, ['find', `${cwd}/${dir}`, '-type', 'f', '-printf', '%P\t%s\n'])
107 const bad: string[] = []
108 for (const line of local.out.split('\n').filter(Boolean)) {
109 const [p, size] = line.split('\t')
110 if (remoteFiles.get(p) !== Number(size)) bad.push(`${p} (local ${size}, bucket ${remoteFiles.get(p) ?? 'missing'})`)
111 }
112 if (bad.length) return { text: `Uploaded, but the bucket does not match the package:\n` + bad.map(b => ` ${b}`).join('\n') + '\n\nRun /release upload again.' }
113
114 await update($, run, r => (r ? { ...r, stage: 'uploaded' } : r))
115 $.ui.status(`released ${app} ${state.version}: tell the website chat`)
116 const msg = `Release package ready: ${app} ${state.version} at ${dest}/ (${remoteFiles.size} files, listed back and byte-matched). Run /receive ${app} ${state.version}.`
117 return {
118 text: `Package uploaded to ${dest}/ and verified (${remoteFiles.size} files, bytes match).\n\nHand-off message for the website chat:\n\n${msg}`,
119 context: [
120 `Send the hand-off now. ` + (website ? `The website chat is "${website}": SendMessage({ to: "${website}", message: <the hand-off message above> }).` : `Call ListAgents, find the website repo's chat (potfoliowebsite / amanorsac.studio), and SendMessage it the hand-off message above.`) +
121 ` If no website chat is running, say so and give the person the message to paste. Then the release is handed off; the website chat does the rest and asks the person for the price and the go-ahead.`,
122 ],
123 }
124 }
125
126 /* ---- /release <version>: check, fetch, lay out, hand the judgement steps to the model ---- */
127 const version = sub.replace(/^v/, '')
128 if (!/^\d+\.\d+\.\d+/.test(version)) return { text: `"${sub}" is not a version. /release 2.0.4` }
129 const inCode = await codeVersion($)
130 if (inCode && inCode !== version) return { text: `The code says ${inCode}, not ${version}. Bump the version and push before releasing.` }
131
132 const rel = await ghRelease($, version)
133 if (!rel) return { text: `No GitHub release tagged v${version} (or ${version}). Push the tag, let the build finish, then run /release ${version} again.` }
134 const assets = rel.assets.filter(a => platformOf(a.name))
135 if (!assets.length) return { text: `The GitHub release v${version} has no installers attached yet. Wait for the build, then run /release ${version} again.` }
136
137 const dir = `release/${version}`
138 await sh($, ['mkdir', '-p', `${dir}/installers`, `${dir}/screenshots`, `${dir}/brag`])
139 try {
140 const excl = await $.fs.read('.git/info/exclude').catch(() => '')
141 if (!excl.includes('release/')) await $.fs.write('.git/info/exclude', excl.replace(/\s*$/, '\n') + 'release/\n')
142 } catch {}
143 const rows: string[] = []
144 for (const a of assets) {
145 const dl = await sh($, ['gh', 'release', 'download', `v${version}`, '--pattern', a.name, '--dir', `${dir}/installers`, '--clobber'], 600_000)
146 if (!dl.ok) {
147 const dl2 = await sh($, ['curl', '-fsSL', '-o', `${dir}/installers/${a.name}`, a.browser_download_url], 600_000)
148 if (!dl2.ok) return { text: `Could not download ${a.name}: ${dl2.err.slice(0, 200)}` }
149 }
150 const sum = await sh($, ['sha256sum', `${dir}/installers/${a.name}`])
151 const st = await $.fs.stat(`${dir}/installers/${a.name}`)
152 rows.push(`| ${platformOf(a.name)} | ${a.name} | ${st.size} | ${sum.ok ? sum.out.split(/\s+/)[0] : '?'} |`)
153 }
154 const log = await sh($, ['git', 'log', '--oneline', '-40'])
155 const today = new Date().toISOString().slice(0, 10)
156 const platforms = [...new Set(assets.map(a => platformOf(a.name)))].join(', ')
157 const skeleton =
158`# ${app} ${version}
159
160## Release
161- app: ${app}
162- version: ${version}
163- date: ${today}
164- platforms: ${platforms}
165- licensed: <yes|no>
166- free: <yes|no>
167- price: unchanged | ask
168
169## What's new
170- <one line per change a buyer would notice, most important first>
171
172## Email owners
173<yes|no>
174<one sentence for the email, if yes>
175
176## Installers
177| platform | file | bytes | sha256 |
178${rows.join('\n')}
179
180## Screenshots
181| file | shows |
182| 01-<what>.png | <what it shows> |
183
184## Website
185- <anything the page must say differently now>
186- <anything that MUST NOT appear in public yet>
187
188## Known issues
189- <what a buyer might hit, and what to tell them>
190`
191 await $.fs.write(`${dir}/handoff.md`, skeleton)
192 await update($, run, () => ({ version, dir, stage: 'recording' }))
193 $.ui.status(`releasing ${app} ${version}: record, brag, notes, then /release upload`)
194
195 return {
196 text: `Release ${version}: GitHub build found, ${assets.length} installer${assets.length === 1 ? '' : 's'} downloaded and summed into ${dir}/installers/. A handoff.md skeleton is in ${dir}/.\n\nNext, with the model: record the app, run /brag, finish the notes. Then /release upload.`,
197 context: [
198 `A release of "${app}" ${version} is laid out in ${dir}/ (installers downloaded and summed, handoff.md is a skeleton with the installer table filled). Finish the package per the Release Standard (docs/release-standard.md in the website repo, §2–§5), in this order, and stop to ask the person wherever a step needs their computer:\n` +
199 `1. Record the app. Desktop app: it is built on this computer; launch the built app, capture every main screen to ${dir}/screenshots/NN-<what>.png in the order a new user meets them (real data, nothing private, no developer paths on screen), and record 30–90 s of it in use to ${dir}/recording.mp4 with no narration. If this chat cannot drive the screen, give the person exact steps and file names and wait. Mobile app: copy the store screenshots from the repo instead.\n` +
200 `2. Run /brag (brag-slim) on this repo with those screenshots and the recording as its material, output into ${dir}/brag/ (brag.mp4, brag.jpg, share-copy.txt). Every line on screen must be the app's own wording; real screens only.\n` +
201 `3. Fill handoff.md: licensed/free/price, What's new from the git log below in a buyer's words, Email owners, the Screenshots table, Website notes, Known issues. Then show it to the person and wait for their read.\n` +
202 `4. When they are happy, tell them to run /release upload.\n` +
203 `Never upload anything to the bucket's ${app}/ folder; only /release upload writes, and only under releases/.\n\nRecent commits:\n${log.out}`,
204 ],
205 }
206 })
207
208 on('prompt.compose', async ($, e, next) => {
209 const { sections } = await next(e)
210 const state = await read($, run)
211 const text =
212 `This app repo releases through /release, per the studio's Release Standard (docs/release-standard.md in the amanorsac.studio website repo): build and test locally, GitHub makes the final installers, /release <version> fetches them and lays out the package, the model records the app and runs /brag and writes handoff.md, /release upload sends the package to R2 under releases/${app || '<app>'}/<version>/ and hands off to the website chat. ` +
213 (state ? `A release is in progress: ${state.version}, stage ${state.stage}, folder ${state.dir}/. ` : '') +
214 `Never upload to the bucket's <app>/ folder from this repo; the website's receiver owns it.`
215 return { sections: [...sections, { id: 'app-release:standard', text, scope: 'session' }] }
216 })
217}
218types/index.d.ts 8 lines1export type ReleaseRun = { version: string; dir: string; stage: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'app-release': { run: ReleaseRun | null }
6 }
7}
8