SLOPSHOPPER

apk-fraicheur

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…

newbandguardcommandtoaststatus
★ 2v0.2.0MITupdated 2026-10-07DarkSawOktay/claude-mods/apk-fraicheur
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · apk-fraicheur
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /apk ⎿ apk-fraicheur: Aucun projet Android ici. Règle « appDir » de apk-fraicheur (/config) pour en suivre un depuis n’importe où. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

tests license MIT

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.

ModWhat it showsCommands
suivi-conso — usage5-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 standFor 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 guardEvery 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 freshnessIs 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

Requirements

  • A recent Claude Code (tested with 2.1), in the terminal or the desktop app.
  • 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.

Install

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.

Settings

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.

Themes

/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.

What the mods do and don't do

  • They read: git, 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.
  • They never change your code, your branches or your server. /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.
  • Nothing leaves your machine: no telemetry, no third-party service.

Develop

claude plugin validate <mod>
claude plugin test <mod>

Rules of the repo, so that every mod can be published:

  • nothing specific to one install in the code: servers, paths and names go through userConfig;
  • every visible text goes through the mod's messages(lang) table, in French and English;
  • calculations live in hooks/logic.ts, with no engine call, tested on their own; rendering lives in hooks/register.tsx, tested on at least two surfaces;
  • a band above the prompt stacks with the other mods' (next(e) drawn below);
  • read-only by default: a mod that refuses or changes something does so behind an explicit setting.

Issues and pull requests are welcome.

License

MIT © Oktay Gençer


Version française

Source 3 files
hooks/register.tsx 284 lines
1// 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}
284
hooks/logic.ts 206 lines
1// 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}
206
types/index.d.ts 42 lines
1/** 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