Android APK freshness: is the APK up to date with the code, build in progress and its duration, failures and out-of-memory caught at once, size, copy to…

Mods for Claude Code: small plugins that put on screen what you would otherwise keep asking Claude. One folder per mod; install only the ones you want. Every mod speaks English or French.
| Mod | What it shows | Commands |
|---|---|---|
suivi-conso — usage | 5-hour and weekly quota left, context in tokens, session cost (API equivalent), when the quota runs out at the current pace, what cost the most, tips to save usage, 7-day history. Also provides the shared themes | /conso, /conso band, /theme-mods <theme> |
ou-on-en-est — where things stand | For each repo: branch, uncommitted, unpushed, to pull, PR and CI. On your server: the deployed commit against main, changes made in place, whether your pages answer. Notes per repo | /ou-on-en-est, /ou-on-en-est note <repo> <text> |
garde-prod — production guard | Every command that touches production or is risky (push --force, secrets, rm -rf…), a fresh database backup before a production write (can refuse without one), and refused commands kept ready to run yourself with ! | /prod |
apk-fraicheur — APK freshness | Is the Android APK up to date with the code? Build in progress and its duration, failure or out-of-memory flagged at once, size, copy to another folder | /apk, /apk copy |
A band above the prompt (terminal and desktop) gives the essentials; each command opens a pane with the details, which also works on mobile.
5 h ███████░░░ 62% left │ 7 d 42% │ ctx 342 k / 1 M │ ≈ $4.82 /conso
PROD ×2 · backup ✓ 21:10 · 1 run yourself (/prod)
APK v4 · 14:52 ✗ stale: 2 commits since the build · 96 MB
git for ou-on-en-est and apk-fraicheur; a signed-in gh to see PRs and CI; key-based ssh to follow a server.git clone https://github.com/DarkSawOktay/claude-mods ~/claude-mods
In ~/.claude/settings.json (your user settings, not a project's), one path per mod you want, separated by ::
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-mods/suivi-conso:~/claude-mods/ou-on-en-est:~/claude-mods/garde-prod:~/claude-mods/apk-fraicheur"
}
}
To try one mod once: claude --plugin-dir ~/claude-mods/garde-prod. To update: git pull in ~/claude-mods; Claude Code reloads a mod when its files change.
In /config, or under pluginConfigs in ~/.claude/settings.json:
{
"pluginConfigs": {
"suivi-conso": { "options": { "language": "en" } },
"garde-prod": { "options": { "language": "en", "prodHosts": "example.com,203.0.113.7", "requireBackup": true } },
"ou-on-en-est": { "options": { "language": "en", "vpsHost": "deploy@example.com", "vpsRepos": "api=/srv/api", "healthUrls": "https://example.com/" } },
"apk-fraicheur": { "options": { "language": "en", "appDir": "/home/me/projects/my-app", "copyTo": "/mnt/c/Users/me/Downloads" } }
}
}
Every mod has language: fr (default) or en.
garde-prod
prodHosts: production hostnames or IPs. Without them, only risky commands are flagged.backupPattern: regular expression for your own backup command, on top of pg_dump, mysqldump, mongodump.backupMaxAgeMinutes (120): how old a backup may be and still cover a write.requireBackup (off): refuse a production database write without a recent backup. Claude gets the reason and takes the backup first.ou-on-en-est
projectsRoot: every git repo directly inside is tracked. Empty: the current project and the repos next to it.extraRepos: other paths, comma-separated.vpsHost, vpsRepos: your server (key-based ssh) and name=path for each deployed repo. Read-only: a single ssh running git rev-parse and git status, every 10 minutes and on each /ou-on-en-est.healthUrls: pages whose HTTP status is shown.apk-fraicheur
appDir: the project followed when the current folder is not an Android app.copyTo: where /apk copy puts the APK./theme-mods graphite | olive | crepuscule | papier | contraste (provided by suivi-conso). The choice is kept across sessions and written to ~/.claude/mods-theme, which every mod reads. The rest of Claude Code follows /theme.
gh, the files of your project, and with ou-on-en-est one read-only ssh and curl per check. ou-on-en-est also runs git fetch, which only updates remote-tracking branches./apk copy copies the APK when you ask. garde-prod with requireBackup on refuses a production database write until a backup exists; that is the only time a mod blocks anything.claude plugin validate <mod>
claude plugin test <mod>
Rules of the repo, so that every mod can be published:
userConfig;messages(lang) table, in French and English;hooks/logic.ts, with no engine call, tested on their own; rendering lives in hooks/register.tsx, tested on at least two surfaces;next(e) drawn below);Issues and pull requests are welcome.
MIT © Oktay Gençer
hooks/register.tsx 284 lines1// APK fraîcheur : l'APK reflète-t-il le code ? Build en cours, échec, taille, copie.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Apk, Build, Freshness } from '../types'
6import {
7 APK_DIRS,
8 classifyBuildOutput,
9 dirtyPaths,
10 formatDuration,
11 formatSize,
12 formatWhen,
13 isBuildCommand,
14 lastGradleTask,
15 messages,
16 parseVersionCode,
17 parseVersionName,
18 SEND_LIMIT_BYTES,
19 statusText,
20 toLang,
21 verdict,
22} from './logic'
23import type { Lang } from './logic'
24
25const appDir = atom({ plugin: 'apk-fraicheur', key: 'appDir' } as const, null)
26const apk = atom({ plugin: 'apk-fraicheur', key: 'apk' } as const, null)
27const freshness = atom({ plugin: 'apk-fraicheur', key: 'freshness' } as const, null)
28const build = atom({ plugin: 'apk-fraicheur', key: 'build' } as const, null)
29const lastDurationMs = atom({ plugin: 'apk-fraicheur', key: 'lastDurationMs' } as const, null)
30const now = atom({ plugin: 'apk-fraicheur', key: 'now' } as const, 0)
31
32/** Couleurs du thème choisi dans suivi-conso (/theme-mods), Graphite sinon. */
33const COLORS: Record<string, { acc: string; ok: string; warn: string; bad: string; dim: string }> = {
34 graphite: { acc: '#7cc4fa', ok: '#4cc38a', warn: '#e6ad4c', bad: '#e5675e', dim: '#8c95a2' },
35 olive: { acc: '#d6a94e', ok: '#62c08c', warn: '#e6ad4c', bad: '#e2665d', dim: '#80968a' },
36 crepuscule: { acc: '#f4a988', ok: '#6fd3a8', warn: '#e9c46a', bad: '#ef6f6c', dim: '#9d94b0' },
37 papier: { acc: '#2f62c8', ok: '#1f7a4d', warn: '#9a6200', bad: '#c23b32', dim: '#676b72' },
38 contraste: { acc: '#ffd400', ok: '#5cff7a', warn: '#ffd400', bad: '#ff5c5c', dim: '#cfcfcf' },
39}
40
41let lastRefresh = 0
42
43let themeName = 'graphite'
44let lang: Lang = 'fr'
45let m = messages(lang)
46
47/** Relit le thème commun écrit par /theme-mods (fichier ~/.claude/mods-theme). */
48async function loadTheme($: EngineInterface) {
49 try {
50 const home = await $.env.get('HOME')
51 if (!home || !(await $.fs.exists(`${home}/.claude/mods-theme`))) return
52 const name = (await $.fs.read(`${home}/.claude/mods-theme`)).trim()
53 if (name in COLORS) themeName = name
54 } catch {
55 // thème par défaut
56 }
57}
58
59function palette() {
60 return COLORS[themeName] ?? COLORS.graphite!
61}
62
63/** Le projet suivi : le dossier courant s'il a un `android/app`, sinon le réglage. */
64async function findAppDir($: EngineInterface, cwd: string, configured: string): Promise<string | null> {
65 for (const dir of [cwd, configured]) {
66 if (dir && (await $.fs.exists(`${dir}/android/app`))) return dir
67 }
68 return null
69}
70
71/** Le dernier APK sorti de Gradle, avec sa version. */
72async function findApk($: EngineInterface, dir: string): Promise<Apk | null> {
73 let best: { path: string; size: number; mtimeMs: number } | null = null
74 for (const sub of APK_DIRS) {
75 const full = `${dir}/${sub}`
76 if (!(await $.fs.exists(full))) continue
77 for (const entry of await $.fs.list(full)) {
78 if (entry.kind !== 'file' || !entry.name.endsWith('.apk')) continue
79 if (!best || entry.mtimeMs > best.mtimeMs) best = { path: `${full}/${entry.name}`, size: entry.size, mtimeMs: entry.mtimeMs }
80 }
81 if (best) break
82 }
83 if (!best) return null
84 let versionCode: number | null = null
85 let versionName: string | null = null
86 for (const file of ['android/app/build.gradle', 'app.json']) {
87 if (!(await $.fs.exists(`${dir}/${file}`))) continue
88 const text = await $.fs.read(`${dir}/${file}`)
89 versionCode ??= parseVersionCode(text)
90 versionName ??= parseVersionName(text)
91 }
92 return { path: best.path, sizeBytes: best.size, builtAt: best.mtimeMs, versionCode, versionName }
93}
94
95/** Commits et fichiers modifiés après le build. */
96async function findFreshness($: EngineInterface, dir: string, a: Apk): Promise<Freshness | null> {
97 const since = Math.floor(a.builtAt / 1000)
98 const log = await $.process.run(['git', '-C', dir, 'log', `--since=@${since}`, '--format=%h', 'HEAD'])
99 if (log.exitCode !== 0) return null
100 const head = await $.process.run(['git', '-C', dir, 'rev-parse', '--short', 'HEAD'])
101 const status = await $.process.run(['git', '-C', dir, 'status', '--porcelain'])
102 let dirtySince = 0
103 for (const path of dirtyPaths(status.stdout).slice(0, 200)) {
104 try {
105 const st = await $.fs.stat(`${dir}/${path}`)
106 if (st.mtimeMs > a.builtAt) dirtySince += 1
107 } catch {
108 dirtySince += 1 // supprimé depuis le build
109 }
110 }
111 return {
112 commitsSince: log.stdout.split('\n').filter(Boolean).length,
113 dirtySince,
114 headSha: head.stdout.trim(),
115 }
116}
117
118async function refresh($: EngineInterface) {
119 lastRefresh = await $.clock.now()
120 const dir = await read($, appDir)
121 if (!dir) return
122 const a = await findApk($, dir)
123 await update($, apk, () => a)
124 const f = a ? await findFreshness($, dir, a) : null
125 await update($, freshness, () => f)
126 await setStatus($)
127}
128
129async function setStatus($: EngineInterface) {
130 $.ui.status(statusText(await read($, apk), await read($, freshness), await read($, build), await $.clock.now(), lang))
131}
132
133/** Clôt le build suivi, prévient, et relit l'APK. */
134async function finish($: EngineInterface, status: Build['status'], detail: string | undefined) {
135 const t = await $.clock.now()
136 const b = await read($, build)
137 if (!b || b.status !== 'running') return
138 await update($, build, () => ({ ...b, status, endedAt: t, detail: detail ?? b.detail }))
139 await refresh($)
140 const a = await read($, apk)
141 if (status === 'ok') {
142 await update($, lastDurationMs, () => t - b.startedAt)
143 const what = a ? ` · APK${a.versionCode ? ` v${a.versionCode}` : ''} ${formatSize(a.sizeBytes, lang)}` : ''
144 $.ui.toast(m.toastOk(formatDuration(t - b.startedAt), what), { timeoutMs: 10_000 })
145 } else {
146 $.ui.toast(m.toastFailed(formatDuration(t - b.startedAt), detail ?? m.unknownCause), { timeoutMs: 15_000 })
147 }
148}
149
150/** Toutes les 15 s : un build Gradle tourne-t-il (lancé par Claude ou à la main) ? */
151async function tick($: EngineInterface) {
152 const t = await $.clock.now()
153 await update($, now, () => t)
154 if (!(await read($, appDir))) return
155 const ps = await $.process.run(['pgrep', '-f', 'GradleWrapperMain'])
156 const isRunning = ps.exitCode === 0
157 const b = await read($, build)
158 if (isRunning && b?.status !== 'running') {
159 await update($, build, () => ({ startedAt: t, command: m.outsideClaude, status: 'running' as const }))
160 } else if (!isRunning && b?.status === 'running' && t - b.startedAt > 20_000) {
161 const before = (await read($, apk))?.builtAt ?? 0
162 await refresh($)
163 const after = (await read($, apk))?.builtAt ?? 0
164 await finish($, after > before || after > b.startedAt ? 'ok' : 'failed', after > b.startedAt ? undefined : m.noApkProduced)
165 }
166 if (t - lastRefresh > 60_000) {
167 await loadTheme($)
168 await refresh($)
169 }
170 else await setStatus($)
171}
172
173export const register: Register = (on, options) => {
174 lang = toLang(options.language)
175 m = messages(lang)
176
177 on('session.start', async ($, e, next) => {
178 await $.command.register({
179 name: 'apk',
180 description: m.commandDesc,
181 argumentHint: m.commandHint,
182 })
183 const dir = await findAppDir($, e.cwd, String(options.appDir ?? ''))
184 await update($, appDir, () => dir)
185 await loadTheme($)
186 await refresh($)
187 $.clock.every(15_000, () => void tick($))
188 return next(e)
189 })
190
191 on('turn.complete', async ($, e, next) => {
192 if ((await $.clock.now()) - lastRefresh > 10_000) await refresh($)
193 return next(e)
194 })
195
196 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
197 if (!isBuildCommand(e.command)) return next(e)
198 const t = await $.clock.now()
199 await update($, build, () => ({ startedAt: t, command: e.command.slice(0, 80), status: 'running' }))
200 await setStatus($)
201 const ran = await next(e)
202 if (e.run_in_background) return ran // la fin sera vue par tick()
203 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : JSON.stringify(ran.result ?? ''))
204 const outcome = classifyBuildOutput(text, lang)
205 const task = lastGradleTask(text)
206 if (outcome) await finish($, outcome.status, outcome.detail)
207 else await finish($, ran.isError ? 'failed' : 'ok', ran.isError ? (task ? m.stoppedDuring(task) : m.error) : undefined)
208 return ran
209 })
210
211 on('command.run', { command: 'apk' }, async ($, e) => {
212 const dir = await read($, appDir)
213 if (!dir) return { text: options.appDir ? m.noProjectIn(String(options.appDir)) : m.noProject }
214 await refresh($)
215 const a = await read($, apk)
216 if (!a) return { text: m.noApkYet(`${dir}/${APK_DIRS[0]}`) }
217
218 if ((m.copyWords as readonly string[]).includes(e.args.trim())) {
219 const to = String(options.copyTo ?? '')
220 if (!to) return { text: m.setCopyTo }
221 const app = dir.split('/').filter(Boolean).pop() ?? 'app'
222 const name = `${app}-${a.versionCode ? `v${a.versionCode}-` : ''}${new Date(a.builtAt).toISOString().slice(0, 10)}.apk`
223 const cp = await $.process.run(['cp', a.path, `${to}/${name}`])
224 return { text: cp.exitCode === 0 ? m.copied(`${to}/${name}`) : m.copyFailed(cp.stderr.trim()) }
225 }
226
227 const t = await $.clock.now()
228 const v = verdict(a, await read($, freshness), lang)
229 const lines = [
230 `APK${a.versionCode ? ` v${a.versionCode}` : ''}${a.versionName ? ` (${a.versionName})` : ''} · ${m.built} ${formatWhen(a.builtAt, t, lang)} · ${formatSize(a.sizeBytes, lang)}`,
231 v.kind === 'stale' ? m.staleLine(v.reason) : m.freshLine,
232 a.path,
233 ]
234 if (a.sizeBytes > SEND_LIMIT_BYTES) lines.push(m.tooBig)
235 return { text: lines.join('\n') }
236 })
237
238 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
239 const below = await next(e)
240 const a = await read($, apk)
241 const b = await read($, build)
242 if (e.props.hasSurvey || (!a && !b)) return below
243 const { Box, Text } = $.ui.resolve(e)
244 const c = palette()
245 const t = Math.max(await read($, now), await $.clock.now())
246 const f = await read($, freshness)
247 const v = verdict(a, f, lang)
248 const isWide = e.props.bodyColumns >= 90
249 const recentFail = b?.status === 'failed' && b.endedAt !== undefined && t - b.endedAt < 30 * 60_000
250 const typical = await read($, lastDurationMs)
251
252 return (
253 <Box flexDirection="column">
254 <Box flexDirection="row" flexWrap="wrap">
255 <Text color={c.acc}>APK </Text>
256 {b?.status === 'running' && (
257 <Text>
258 <Text color={c.warn}>
259 {m.building} {formatDuration(t - b.startedAt)}
260 </Text>
261 {typical !== null && <Text color={c.dim}> ({m.last}{lang === 'en' ? ': ' : ' : '}{formatDuration(typical)})</Text>}
262 {isWide && b.detail && <Text color={c.dim}> · {b.detail}</Text>}
263 </Text>
264 )}
265 {b?.status !== 'running' && recentFail && <Text color={c.bad}>✗ {m.buildFailed(b?.detail ?? '')} </Text>}
266 {b?.status !== 'running' && a && (
267 <Text>
268 <Text>
269 {a.versionCode ? `v${a.versionCode} · ` : ''}
270 {formatWhen(a.builtAt, t, lang)}{' '}
271 </Text>
272 {v.kind === 'fresh' && <Text color={c.ok}>✓ {m.fresh}</Text>}
273 {v.kind === 'stale' && <Text color={c.warn}>✗ {m.stale}{lang === 'en' ? ': ' : ' : '}{v.reason}</Text>}
274 {isWide && <Text color={c.dim}> · {formatSize(a.sizeBytes, lang)}</Text>}
275 {isWide && a.sizeBytes > SEND_LIMIT_BYTES && <Text color={c.dim}> ({m.tooBigBand})</Text>}
276 </Text>
277 )}
278 </Box>
279 {below}
280 </Box>
281 )
282 })
283}
284hooks/logic.ts 206 lines1// Calculs purs du mod APK : rien ici ne touche à l'engine, tout se teste seul.
2import type { Apk, Build, Freshness } from '../types'
3
4export type Lang = 'fr' | 'en'
5
6/** La langue d'un réglage : `en`, sinon le français. */
7export function toLang(value: unknown): Lang {
8 return value === 'en' ? 'en' : 'fr'
9}
10
11const plural = (n: number, word: string) => `${word}${n > 1 ? 's' : ''}`
12
13/** Taille au-delà de laquelle l'envoi direct d'un fichier échoue (30 Mio). */
14export const SEND_LIMIT_BYTES = 30 * 1024 * 1024
15
16/** Où Gradle dépose les APK, du plus utile au moins utile. */
17export const APK_DIRS = ['android/app/build/outputs/apk/release', 'android/app/build/outputs/apk/debug']
18
19/** Dossiers de build à ignorer quand on cherche des fichiers modifiés. */
20const BUILD_OUTPUT = /^(android\/app\/build|android\/build|android\/\.gradle|ios\/build|node_modules|\.expo)\//
21
22/** `versionCode 4` (build.gradle) ou `"versionCode": 4` (app.json). */
23export function parseVersionCode(text: string): number | null {
24 const m = text.match(/versionCode["']?\s*[:=]?\s*(\d+)/)
25 return m?.[1] ? Number(m[1]) : null
26}
27
28/** `versionName "1.1.0"` (build.gradle) ou `"version": "1.1.0"` (app.json). */
29export function parseVersionName(text: string): string | null {
30 const m = text.match(/versionName\s+["']([^"']+)["']/) ?? text.match(/"version"\s*:\s*"([^"]+)"/)
31 return m?.[1] ?? null
32}
33
34/** Une commande qui lance un build Android. */
35export function isBuildCommand(command: string): boolean {
36 return /gradlew[^|;&]*\b(assemble|bundle)\w*/i.test(command) || /\beas\s+build\b/.test(command) || /expo\s+run:android/.test(command)
37}
38
39export type BuildOutcome = { status: 'ok' | 'failed'; detail: string }
40
41/** Lit la sortie d'un build : réussite, échec, manque de mémoire, processus tué. */
42export function classifyBuildOutput(text: string, lang: Lang = 'fr'): BuildOutcome | null {
43 const m = messages(lang)
44 const oom = text.match(/OutOfMemoryError(?::\s*([\w ]+))?/)
45 if (oom) return { status: 'failed', detail: m.oom(oom[1]?.trim() ?? '') }
46 if (/\bKilled\b|exit code 137|signal 9/.test(text)) return { status: 'failed', detail: m.killed }
47 const ok = text.match(/BUILD SUCCESSFUL in ([\w ]+)/)
48 if (ok) return { status: 'ok', detail: m.succeededIn(ok[1]?.trim() ?? '') }
49 if (/BUILD FAILED|FAILURE: Build failed/.test(text)) {
50 const what = text.match(/\* What went wrong:\s*\n([^\n]+)/)
51 return { status: 'failed', detail: what?.[1]?.trim() ?? 'BUILD FAILED' }
52 }
53 return null
54}
55
56/** La dernière tâche Gradle affichée : `> Task :app:mergeReleaseResources`. */
57export function lastGradleTask(text: string): string | null {
58 const tasks = text.match(/> Task :[\w:-]+/g)
59 return tasks?.[tasks.length - 1]?.slice(2) ?? null
60}
61
62/** Les fichiers modifiés de `git status --porcelain`, sans les sorties de build. */
63export function dirtyPaths(porcelain: string): string[] {
64 return porcelain
65 .split('\n')
66 .map(line => line.slice(3).trim())
67 .map(path => (path.includes(' -> ') ? (path.split(' -> ')[1] ?? path) : path))
68 .filter(path => path.length > 0 && !BUILD_OUTPUT.test(path))
69}
70
71export type Verdict =
72 | { kind: 'none' }
73 | { kind: 'fresh' }
74 | { kind: 'stale'; reason: string }
75
76/** L'APK reflète-t-il le code actuel ? */
77export function verdict(apk: Apk | null, f: Freshness | null, lang: Lang = 'fr'): Verdict {
78 const m = messages(lang)
79 if (!apk) return { kind: 'none' }
80 if (!f || (f.commitsSince === 0 && f.dirtySince === 0)) return { kind: 'fresh' }
81 const parts: string[] = []
82 if (f.commitsSince > 0) parts.push(`${f.commitsSince} ${plural(f.commitsSince, 'commit')}`)
83 if (f.dirtySince > 0) parts.push(m.changedFiles(f.dirtySince))
84 return { kind: 'stale', reason: m.sinceBuild(parts.join(' + ')) }
85}
86
87/** 100 270 080 → « 96 Mo » (« 96 MB » en anglais). */
88export function formatSize(bytes: number, lang: Lang = 'fr'): string {
89 const mo = bytes / (1024 * 1024)
90 const unit = lang === 'en' ? 'MB' : 'Mo'
91 if (mo >= 10) return `${Math.round(mo)} ${unit}`
92 const one = mo.toFixed(1)
93 return `${lang === 'en' ? one : one.replace('.', ',')} ${unit}`
94}
95
96/** 760 000 → « 12 min 40 s ». */
97export function formatDuration(ms: number): string {
98 const s = Math.max(0, Math.round(ms / 1000))
99 if (s < 60) return `${s} s`
100 const m = Math.floor(s / 60)
101 if (m < 60) return `${m} min ${String(s % 60).padStart(2, '0')} s`
102 return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}`
103}
104
105/** « 14:52 » aujourd'hui, « hier 21:10 », sinon « 7 sept. 21:10 ». */
106export function formatWhen(ms: number, now: number, lang: Lang = 'fr'): string {
107 const m = messages(lang)
108 const d = new Date(ms)
109 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
110 const day = (x: number) => new Date(x).toDateString()
111 if (day(ms) === day(now)) return hm
112 if (day(ms) === day(now - 86_400_000)) return `${m.yesterday} ${hm}`
113 return `${d.toLocaleDateString(m.locale, { day: 'numeric', month: 'short' })} ${hm}`
114}
115
116/** Le texte court de la ligne d'état. */
117export function statusText(apk: Apk | null, f: Freshness | null, build: Build | null, now: number, lang: Lang = 'fr'): string | undefined {
118 const m = messages(lang)
119 if (build?.status === 'running') return `${m.buildAndroid} ${formatDuration(now - build.startedAt)}`
120 if (build?.status === 'failed' && build.endedAt && now - build.endedAt < 30 * 60_000) return m.buildFailed(build.detail ?? '')
121 const v = verdict(apk, f, lang)
122 if (v.kind === 'none') return undefined
123 const version = apk?.versionCode ? `v${apk.versionCode} ` : ''
124 return `APK ${version}${v.kind === 'fresh' ? m.fresh : m.stale}`
125}
126
127const MESSAGES = {
128 fr: {
129 oom: (what: string) => `manque de mémoire (OutOfMemoryError${what ? ` : ${what}` : ''})`,
130 killed: 'processus tué (souvent la mémoire)',
131 succeededIn: (d: string) => `réussi en ${d}`,
132 changedFiles: (n: number) => `${n} ${plural(n, 'fichier')} ${plural(n, 'modifié')}`,
133 sinceBuild: (what: string) => `${what} depuis le build`,
134 yesterday: 'hier',
135 locale: 'fr-FR',
136 buildAndroid: 'build Android',
137 buildFailed: (detail: string) => `build échoué : ${detail}`,
138 fresh: 'à jour',
139 stale: 'périmé',
140 outsideClaude: 'gradlew (lancé hors de Claude)',
141 noApkProduced: "arrêté sans produire d'APK",
142 toastOk: (d: string, what: string) => `Build Android réussi en ${d}${what}`,
143 toastFailed: (d: string, detail: string) => `Build Android échoué après ${d} : ${detail}`,
144 unknownCause: 'cause inconnue',
145 stoppedDuring: (task: string) => `arrêté pendant ${task}`,
146 error: 'erreur',
147 commandDesc: "État de l'APK Android (« /apk copier » le dépose dans le dossier réglé)",
148 commandHint: '[copier]',
149 copyWords: ['copier', 'copy'],
150 noProjectIn: (dir: string) => `Aucun projet Android ici ni dans ${dir}.`,
151 noProject: 'Aucun projet Android ici. Règle « appDir » de apk-fraicheur (/config) pour en suivre un depuis n’importe où.',
152 noApkYet: (where: string) => `Pas encore d'APK dans ${where}.`,
153 setCopyTo: 'Règle d’abord « copyTo » de apk-fraicheur (/config), ex. /mnt/c/Users/<toi>/Downloads.',
154 copied: (to: string) => `APK copié : ${to}`,
155 copyFailed: (err: string) => `Copie impossible : ${err}`,
156 built: 'construit',
157 staleLine: (reason: string) => `Périmé : ${reason}.`,
158 freshLine: 'À jour avec le code.',
159 tooBig: 'Trop gros pour l’envoi direct (30 Mo) : « /apk copier » le dépose dans le dossier réglé.',
160 building: 'build en cours',
161 last: 'dernier',
162 tooBigBand: "trop gros pour l'envoi : /apk copier",
163 },
164 en: {
165 oom: (what: string) => `out of memory (OutOfMemoryError${what ? `: ${what}` : ''})`,
166 killed: 'process killed (often memory)',
167 succeededIn: (d: string) => `succeeded in ${d}`,
168 changedFiles: (n: number) => `${n} ${plural(n, 'file')} changed`,
169 sinceBuild: (what: string) => `${what} since the build`,
170 yesterday: 'yesterday',
171 locale: 'en-GB',
172 buildAndroid: 'Android build',
173 buildFailed: (detail: string) => `build failed: ${detail}`,
174 fresh: 'up to date',
175 stale: 'stale',
176 outsideClaude: 'gradlew (started outside Claude)',
177 noApkProduced: 'stopped without producing an APK',
178 toastOk: (d: string, what: string) => `Android build succeeded in ${d}${what}`,
179 toastFailed: (d: string, detail: string) => `Android build failed after ${d}: ${detail}`,
180 unknownCause: 'unknown cause',
181 stoppedDuring: (task: string) => `stopped during ${task}`,
182 error: 'error',
183 commandDesc: 'Android APK status (“/apk copy” puts it in the configured folder)',
184 commandHint: '[copy]',
185 copyWords: ['copy', 'copier'],
186 noProjectIn: (dir: string) => `No Android project here or in ${dir}.`,
187 noProject: 'No Android project here. Set apk-fraicheur’s “appDir” (/config) to follow one from anywhere.',
188 noApkYet: (where: string) => `No APK yet in ${where}.`,
189 setCopyTo: 'First set apk-fraicheur’s “copyTo” (/config), e.g. /mnt/c/Users/<you>/Downloads.',
190 copied: (to: string) => `APK copied: ${to}`,
191 copyFailed: (err: string) => `Copy failed: ${err}`,
192 built: 'built',
193 staleLine: (reason: string) => `Stale: ${reason}.`,
194 freshLine: 'Up to date with the code.',
195 tooBig: 'Too big to send directly (30 MB): “/apk copy” puts it in the configured folder.',
196 building: 'building',
197 last: 'last',
198 tooBigBand: 'too big to send: /apk copy',
199 },
200} as const
201
202/** Les textes affichés, dans la langue du réglage `language`. */
203export function messages(lang: Lang) {
204 return MESSAGES[lang]
205}
206types/index.d.ts 42 lines1/** Le dernier APK trouvé dans les sorties de Gradle. */
2export type Apk = {
3 path: string
4 sizeBytes: number
5 /** Date de fin du build (mtime du fichier). */
6 builtAt: number
7 versionCode: number | null
8 versionName: string | null
9}
10
11/** Ce qui a changé dans le code depuis ce build. */
12export type Freshness = {
13 /** Commits faits après le build. */
14 commitsSince: number
15 /** Fichiers modifiés (non commités) après le build. */
16 dirtySince: number
17 headSha: string
18}
19
20/** Un build Android suivi : lancé par Claude, ou à la main avec `!`. */
21export type Build = {
22 startedAt: number
23 command: string
24 status: 'running' | 'ok' | 'failed'
25 endedAt?: number
26 /** La dernière tâche Gradle vue, ou la cause d'un échec. */
27 detail?: string
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'apk-fraicheur': {
33 appDir: string | null
34 apk: Apk | null
35 freshness: Freshness | null
36 build: Build | null
37 lastDurationMs: number | null
38 now: number
39 }
40 }
41}
42