Where things stand: every repo's branch, uncommitted and unpushed work, PR and CI; on your server, the deployed commit against main and changes made in place…

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 287 lines1// Où on en est : l'état de tes dépôts et de ce qui tourne sur le VPS, dans un panneau.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, PluginOptions, Register } from 'claude-code'
4
5import type { Deployed, Health, RepoState } from '../types'
6import { messages, parsePairs, parsePr, parseStatus, relationText, repoFlags, splitList, summary, toLang } from './logic'
7import type { Lang } from './logic'
8
9const repos = atom({ plugin: 'ou-on-en-est', key: 'repos' } as const, [])
10const deployed = atom({ plugin: 'ou-on-en-est', key: 'deployed' } as const, [])
11const health = atom({ plugin: 'ou-on-en-est', key: 'health' } as const, [])
12const checkedAt = atom({ plugin: 'ou-on-en-est', key: 'checkedAt' } as const, null)
13const isRefreshing = atom({ plugin: 'ou-on-en-est', key: 'isRefreshing' } as const, false)
14
15const PANE = 'ou-on-en-est'
16
17/** Couleurs du thème choisi avec /theme-mods (fichier ~/.claude/mods-theme). */
18const COLORS: Record<string, { acc: string; ok: string; warn: string; bad: string; dim: string }> = {
19 graphite: { acc: '#7cc4fa', ok: '#4cc38a', warn: '#e6ad4c', bad: '#e5675e', dim: '#8c95a2' },
20 olive: { acc: '#d6a94e', ok: '#62c08c', warn: '#e6ad4c', bad: '#e2665d', dim: '#80968a' },
21 crepuscule: { acc: '#f4a988', ok: '#6fd3a8', warn: '#e9c46a', bad: '#ef6f6c', dim: '#9d94b0' },
22 papier: { acc: '#2f62c8', ok: '#1f7a4d', warn: '#9a6200', bad: '#c23b32', dim: '#676b72' },
23 contraste: { acc: '#ffd400', ok: '#5cff7a', warn: '#ffd400', bad: '#ff5c5c', dim: '#cfcfcf' },
24}
25let themeName = 'graphite'
26let notes: Record<string, string> = {}
27let lastLocal = 0
28let cwd = ''
29let lang: Lang = 'fr'
30let m = messages(lang)
31
32async function loadTheme($: EngineInterface) {
33 try {
34 const home = await $.env.get('HOME')
35 if (!home || !(await $.fs.exists(`${home}/.claude/mods-theme`))) return
36 const name = (await $.fs.read(`${home}/.claude/mods-theme`)).trim()
37 if (name in COLORS) themeName = name
38 } catch {
39 // thème par défaut
40 }
41}
42
43async function git($: EngineInterface, dir: string, args: string[], timeoutMs = 10_000) {
44 return $.process.run(['git', '-C', dir, ...args], { timeoutMs })
45}
46
47/** Les dépôts suivis : ceux du dossier des projets, plus la liste en réglage. */
48async function listRepos($: EngineInterface, options: PluginOptions): Promise<string[]> {
49 const out: string[] = []
50 // Sans réglage : le projet courant et ses voisins (le dossier parent).
51 const root = String(options.projectsRoot ?? '') || cwd.replace(/\/[^/]+\/?$/, '')
52 if (cwd && (await $.fs.exists(`${cwd}/.git`))) out.push(cwd)
53 if (root && (await $.fs.exists(root))) {
54 for (const entry of await $.fs.list(root)) {
55 const dir = `${root}/${entry.name}`
56 if (entry.kind === 'dir' && !out.includes(dir) && (await $.fs.exists(`${dir}/.git`))) out.push(dir)
57 }
58 }
59 for (const dir of splitList(String(options.extraRepos ?? ''))) {
60 if (!out.includes(dir) && (await $.fs.exists(`${dir}/.git`))) out.push(dir)
61 }
62 return out
63}
64
65/** Lit un dépôt ; avec `network`, récupère d'abord le distant et la PR. */
66async function readRepo($: EngineInterface, dir: string, network: boolean): Promise<RepoState> {
67 const name = dir.split('/').filter(Boolean).pop() ?? dir
68 const base = { name, path: dir, note: notes[name] ?? null }
69 if (network) await git($, dir, ['fetch', '--quiet', '--prune'], 20_000)
70 const st = await git($, dir, ['status', '--porcelain=v2', '--branch'])
71 if (st.exitCode !== 0) {
72 return { ...base, branch: '?', dirty: 0, ahead: 0, behind: 0, noUpstream: true, aheadOfMain: 0, mainBranch: 'main', mainSha: null, pr: null, error: m.statusFailed }
73 }
74 const s = parseStatus(st.stdout)
75 const head = await git($, dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
76 let mainBranch = head.exitCode === 0 ? head.stdout.trim().replace(/^origin\//, '') : 'main'
77 let mainSha = await git($, dir, ['rev-parse', '--short', `origin/${mainBranch}`])
78 if (mainSha.exitCode !== 0 && head.exitCode !== 0) {
79 mainBranch = 'master'
80 mainSha = await git($, dir, ['rev-parse', '--short', 'origin/master'])
81 }
82 const hasMain = mainSha.exitCode === 0
83 const ahead = hasMain ? await git($, dir, ['rev-list', '--count', `origin/${mainBranch}..HEAD`]) : null
84 let pr: RepoState['pr'] = null
85 if (network && s.branch !== mainBranch) {
86 const view = await $.process.run(['gh', 'pr', 'view', '--json', 'number,state,statusCheckRollup'], { cwd: dir, timeoutMs: 15_000 }).catch(() => null)
87 if (view && view.exitCode === 0) pr = parsePr(view.stdout)
88 } else {
89 pr = (await read($, repos)).find(r => r.path === dir)?.pr ?? null
90 }
91 return {
92 ...base,
93 branch: s.branch,
94 dirty: s.dirty,
95 ahead: s.ahead,
96 behind: s.behind,
97 noUpstream: s.upstream === null,
98 aheadOfMain: ahead && ahead.exitCode === 0 ? Number(ahead.stdout.trim()) : 0,
99 mainBranch,
100 mainSha: hasMain ? mainSha.stdout.trim() : null,
101 pr,
102 }
103}
104
105/** Ce qui tourne sur le VPS : un seul ssh, en lecture seule, pour tous les dépôts. */
106async function readVps($: EngineInterface, options: PluginOptions, local: RepoState[]): Promise<Deployed[]> {
107 const host = String(options.vpsHost ?? '')
108 const pairs = parsePairs(String(options.vpsRepos ?? ''))
109 if (!host || pairs.length === 0) return []
110 const script = pairs
111 .map(p => `echo "@@${p.name} $(git -C '${p.path.replace(/'/g, '')}' rev-parse --short HEAD 2>/dev/null) $(git -C '${p.path.replace(/'/g, '')}' status --porcelain 2>/dev/null | wc -l)"`)
112 .join('; ')
113 const ssh = await $.process.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=6', host, script], { timeoutMs: 20_000 }).catch(() => null)
114 if (!ssh || ssh.exitCode !== 0) return pairs.map(p => ({ name: p.name, sha: null, relation: 'unreachable', dirty: 0 }))
115 const out: Deployed[] = []
116 for (const p of pairs) {
117 const line = ssh.stdout.split('\n').find(l => l.startsWith(`@@${p.name} `)) ?? ''
118 const [, sha = '', dirtyText = '0'] = line.split(/\s+/)
119 const repo = local.find(r => r.name === p.name)
120 let relation: Deployed['relation'] = 'unknown'
121 if (sha && repo?.mainSha) {
122 if (repo.mainSha.startsWith(sha) || sha.startsWith(repo.mainSha)) relation = 'equal'
123 else if ((await git($, repo.path, ['cat-file', '-e', `${sha}^{commit}`])).exitCode !== 0) relation = 'unknown'
124 else {
125 const isAncestor = await git($, repo.path, ['merge-base', '--is-ancestor', sha, `origin/${repo.mainBranch}`])
126 relation = isAncestor.exitCode === 0 ? 'behind' : 'ahead'
127 }
128 }
129 out.push({ name: p.name, sha: sha || null, relation, dirty: Number(dirtyText) || 0 })
130 }
131 return out
132}
133
134async function readHealth($: EngineInterface, options: PluginOptions): Promise<Health[]> {
135 const out: Health[] = []
136 for (const url of splitList(String(options.healthUrls ?? ''))) {
137 const r = await $.process.run(['curl', '-s', '-o', '/dev/null', '-m', '8', '-w', '%{http_code}', url], { timeoutMs: 12_000 }).catch(() => null)
138 const code = r ? Number(r.stdout.trim()) : 0
139 out.push({ url, code: code > 0 ? code : null })
140 }
141 return out
142}
143
144async function refresh($: EngineInterface, options: PluginOptions, network: boolean) {
145 if (await read($, isRefreshing)) return
146 await update($, isRefreshing, () => true)
147 try {
148 lastLocal = await $.clock.now()
149 const list: RepoState[] = []
150 for (const dir of await listRepos($, options)) list.push(await readRepo($, dir, network))
151 await update($, repos, () => list)
152 if (network) {
153 await update($, deployed, () => [])
154 const vps = await readVps($, options, list)
155 await update($, deployed, () => vps)
156 const h = await readHealth($, options)
157 await update($, health, () => h)
158 const t = await $.clock.now()
159 await update($, checkedAt, () => t)
160 }
161 $.ui.status(summary(list, await read($, deployed), lang))
162 } finally {
163 await update($, isRefreshing, () => false)
164 }
165}
166
167export const register: Register = (on, options) => {
168 lang = toLang(options.language)
169 m = messages(lang)
170
171 on('session.start', async ($, e, next) => {
172 cwd = e.cwd
173 await $.command.register({
174 name: 'ou-on-en-est',
175 description: m.commandDesc,
176 argumentHint: m.commandHint,
177 })
178 const saved = await $.store.get('notes')
179 if (saved && typeof saved === 'object') notes = saved as Record<string, string>
180 await loadTheme($)
181 await refresh($, options, false)
182 // Distant, VPS et pages : toutes les 10 minutes.
183 $.clock.every(10 * 60_000, () => void refresh($, options, true))
184 return next(e)
185 })
186
187 on('turn.complete', async ($, e, next) => {
188 if ((await $.clock.now()) - lastLocal > 30_000) await refresh($, options, false)
189 return next(e)
190 })
191
192 on('command.run', { command: 'ou-on-en-est' }, async ($, e) => {
193 const note = e.args.trim().match(/^note\s+(\S+)\s*(.*)$/)
194 if (note?.[1]) {
195 const [, name, text = ''] = note
196 if (text.trim()) notes[name] = text.trim()
197 else delete notes[name]
198 await $.store.set('notes', notes)
199 await update($, repos, list => list.map(r => (r.name === name ? { ...r, note: notes[name] ?? null } : r)))
200 return { text: text.trim() ? m.noteSet(name, text.trim()) : m.noteRemoved(name) }
201 }
202 await loadTheme($)
203 const opened = await $.ui.open({ id: PANE, title: m.paneTitle })
204 await refresh($, options, true)
205 const line = summary(await read($, repos), await read($, deployed), lang) ?? m.noRepo
206 return { text: opened.isPlaced ? line : `${line}\n${m.widen}` }
207 })
208
209 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210 const { Box, Text, Button } = $.ui.resolve(e)
211 const c = COLORS[themeName] ?? COLORS.graphite!
212 const list = await read($, repos)
213 const vps = await read($, deployed)
214 const pages = await read($, health)
215 const at = await read($, checkedAt)
216 const busy = await read($, isRefreshing)
217 const color = (level: 'bad' | 'warn' | 'ok' | 'dim') => ({ bad: c.bad, warn: c.warn, ok: c.ok, dim: c.dim })[level]
218 const host = String(options.vpsHost ?? '')
219 const hasVpsConfig = parsePairs(String(options.vpsRepos ?? '')).length > 0
220
221 return (
222 <Box flexDirection="column" gap={1}>
223 <Box flexDirection="row" justifyContent="space-between">
224 <Text color={c.dim}>
225 {busy ? m.refreshing : at ? m.checkedAt(new Date(at).toTimeString().slice(0, 5)) : m.localOnly}
226 </Text>
227 <Button key="refresh" label={m.refresh} hotkey="r" onPress={() => void refresh($, options, true)} />
228 </Box>
229
230 {list.length === 0 && <Text color={c.dim}>{m.noRepoPane}</Text>}
231 {list.map(r => (
232 <Box flexDirection="column">
233 <Box flexDirection="row" justifyContent="space-between">
234 <Text bold>{r.name}</Text>
235 <Text color={c.dim} wrap="truncate-end">{r.branch}</Text>
236 </Box>
237 <Text>
238 {repoFlags(r, lang).map((f, i) => (
239 <Text color={color(f.level)}>
240 {i > 0 ? ' · ' : ''}
241 {f.text}
242 </Text>
243 ))}
244 </Text>
245 {r.note && <Text color={c.acc}>⏸ {r.note}</Text>}
246 </Box>
247 ))}
248
249 {host && (
250 <Box flexDirection="column">
251 <Text color={c.dim} bold>
252 {m.server.toUpperCase()} {host.split('@').pop()?.toUpperCase()}
253 </Text>
254 {!hasVpsConfig && <Text color={c.dim}>{m.setVpsRepos}</Text>}
255 {vps.map(d => {
256 const repo = list.find(r => r.name === d.name)
257 const tone = d.relation === 'equal' && d.dirty === 0 ? c.ok : d.relation === 'unreachable' ? c.bad : c.warn
258 return (
259 <Text>
260 <Text>{d.name} </Text>
261 <Text color={c.dim}>{d.sha ?? '—'} </Text>
262 <Text color={tone}>{relationText(d, repo?.mainBranch ?? 'main', lang)}</Text>
263 {d.dirty > 0 && <Text color={c.warn}> · {m.changedOnServer(d.dirty)}</Text>}
264 </Text>
265 )
266 })}
267 </Box>
268 )}
269
270 {pages.length > 0 && (
271 <Box flexDirection="column">
272 <Text color={c.dim} bold>
273 {m.pages.toUpperCase()}
274 </Text>
275 {pages.map(p => (
276 <Text>
277 <Text color={p.code !== null && p.code < 400 ? c.ok : c.bad}>{p.code ?? m.unreachable} </Text>
278 <Text color={c.dim}>{p.url.replace(/^https?:\/\//, '')}</Text>
279 </Text>
280 ))}
281 </Box>
282 )}
283 </Box>
284 )
285 })
286}
287hooks/logic.ts 217 lines1// Lecture pure des sorties de git et gh : rien ici ne touche à l'engine.
2import type { Deployed, PullRequest, RepoState } 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
11/** Pluriel simple : « 2 fichiers », « 1 file ». */
12const plural = (n: number, word: string) => `${word}${n > 1 ? 's' : ''}`
13
14/** Une liste « a, b, c » ou sur plusieurs lignes. */
15export function splitList(value: string): string[] {
16 return value
17 .split(/[,\n]/)
18 .map(s => s.trim())
19 .filter(Boolean)
20}
21
22/** « api=/srv/api, site=/srv/site » → paires nom/chemin. */
23export function parsePairs(value: string): { name: string; path: string }[] {
24 return splitList(value)
25 .map(pair => {
26 const i = pair.indexOf('=')
27 return i > 0 ? { name: pair.slice(0, i).trim(), path: pair.slice(i + 1).trim() } : null
28 })
29 .filter((p): p is { name: string; path: string } => p !== null && p.path.length > 0)
30}
31
32export type BranchStatus = {
33 branch: string
34 upstream: string | null
35 ahead: number
36 behind: number
37 dirty: number
38}
39
40/** Lit `git status --porcelain=v2 --branch`. */
41export function parseStatus(text: string): BranchStatus {
42 let branch = '(detached)'
43 let upstream: string | null = null
44 let ahead = 0
45 let behind = 0
46 let dirty = 0
47 for (const line of text.split('\n')) {
48 if (line.startsWith('# branch.head ')) {
49 const head = line.slice(14).trim()
50 branch = head
51 } else if (line.startsWith('# branch.upstream ')) upstream = line.slice(18).trim()
52 else if (line.startsWith('# branch.ab ')) {
53 const m = line.match(/\+(\d+) -(\d+)/)
54 ahead = Number(m?.[1] ?? 0)
55 behind = Number(m?.[2] ?? 0)
56 } else if (/^[12u?] /.test(line)) dirty += 1
57 }
58 return { branch, upstream, ahead, behind, dirty }
59}
60
61/** Lit `gh pr view --json number,state,statusCheckRollup`. */
62export function parsePr(json: string): PullRequest | null {
63 try {
64 const data = JSON.parse(json) as {
65 number?: number
66 state?: string
67 statusCheckRollup?: { conclusion?: string | null; status?: string; state?: string }[]
68 }
69 if (typeof data.number !== 'number') return null
70 const rollup = data.statusCheckRollup ?? []
71 const results = rollup.map(c => (c.conclusion ?? c.state ?? '').toUpperCase())
72 const isPending = rollup.some(c => c.status && c.status.toUpperCase() !== 'COMPLETED') || results.some(r => r === 'PENDING' || r === '')
73 const checks: PullRequest['checks'] =
74 rollup.length === 0
75 ? 'none'
76 : results.some(r => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED'].includes(r))
77 ? 'fail'
78 : isPending
79 ? 'pending'
80 : 'pass'
81 return { number: data.number, state: data.state ?? 'OPEN', checks }
82 } catch {
83 return null
84 }
85}
86
87/** Les choses à faire dans un dépôt, de la plus pressante à la moins pressante. */
88export function repoFlags(r: RepoState, lang: Lang = 'fr'): { level: 'bad' | 'warn' | 'ok' | 'dim'; text: string }[] {
89 const m = messages(lang)
90 const out: { level: 'bad' | 'warn' | 'ok' | 'dim'; text: string }[] = []
91 if (r.error) return [{ level: 'bad', text: r.error }]
92 if (r.pr?.checks === 'fail') out.push({ level: 'bad', text: m.ciRed(r.pr.number) })
93 if (r.behind > 0) out.push({ level: 'warn', text: m.behind(r.behind) })
94 if (r.ahead > 0) out.push({ level: 'warn', text: m.ahead(r.ahead) })
95 if (r.noUpstream && r.branch !== r.mainBranch) out.push({ level: 'warn', text: m.neverPushed })
96 if (r.dirty > 0) out.push({ level: 'warn', text: m.dirty(r.dirty) })
97 if (r.pr?.checks === 'pending') out.push({ level: 'dim', text: m.ciPending(r.pr.number) })
98 if (r.pr?.checks === 'pass') out.push({ level: 'ok', text: `PR #${r.pr.number} ✓` })
99 if (r.branch !== r.mainBranch && r.aheadOfMain > 0 && !r.pr) {
100 out.push({ level: 'dim', text: m.outsideMain(r.aheadOfMain, r.mainBranch) })
101 }
102 if (out.length === 0) out.push({ level: 'ok', text: m.clean })
103 return out
104}
105
106/** Résumé d'une ligne pour la ligne d'état : « 2 non poussés · 1 CI rouge ». */
107export function summary(repos: RepoState[], deployed: Deployed[], lang: Lang = 'fr'): string | undefined {
108 const m = messages(lang)
109 const unpushed = repos.filter(r => r.ahead > 0 || (r.noUpstream && r.branch !== r.mainBranch)).length
110 const dirty = repos.filter(r => r.dirty > 0).length
111 const red = repos.filter(r => r.pr?.checks === 'fail').length
112 const vps = deployed.filter(d => d.relation === 'ahead' || d.relation === 'behind' || d.dirty > 0).length
113 const parts: string[] = []
114 if (red) parts.push(m.sumRed(red))
115 if (unpushed) parts.push(m.sumUnpushed(unpushed))
116 if (dirty) parts.push(m.sumDirty(dirty))
117 if (vps) parts.push(m.sumServer(vps))
118 return parts.length ? parts.join(' · ') : repos.length ? m.allClean : undefined
119}
120
121/** Texte de la relation VPS ↔ main. */
122export function relationText(d: Deployed, mainBranch: string, lang: Lang = 'fr'): string {
123 const m = messages(lang)
124 switch (d.relation) {
125 case 'equal':
126 return `= ${mainBranch}`
127 case 'behind':
128 return m.relBehind(mainBranch)
129 case 'ahead':
130 return m.relAhead(mainBranch)
131 case 'unreachable':
132 return m.unreachable
133 default:
134 return m.relUnknown
135 }
136}
137
138const MESSAGES = {
139 fr: {
140 ciRed: (n: number) => `PR #${n} CI rouge`,
141 ciPending: (n: number) => `PR #${n} CI en cours`,
142 behind: (n: number) => `↓${n} à récupérer`,
143 ahead: (n: number) => `↑${n} ${plural(n, 'non poussé')}`,
144 neverPushed: 'branche jamais poussée',
145 dirty: (n: number) => `✎ ${n} ${plural(n, 'fichier')}`,
146 outsideMain: (n: number, main: string) => `${n} ${plural(n, 'commit')} hors de ${main}`,
147 clean: 'propre',
148 sumRed: (n: number) => `${n} CI rouge`,
149 sumUnpushed: (n: number) => `${n} ${plural(n, 'dépôt')} ${plural(n, 'non poussé')}`,
150 sumDirty: (n: number) => `${n} avec modifs`,
151 sumServer: (n: number) => `serveur ≠ main ×${n}`,
152 allClean: 'dépôts propres',
153 relBehind: (main: string) => `en retard sur ${main}`,
154 relAhead: (main: string) => `en avance sur ${main}`,
155 unreachable: 'injoignable',
156 relUnknown: 'commit inconnu ici',
157 commandDesc: 'État des dépôts et du serveur (« note <dépôt> <texte> » pour noter une attente)',
158 commandHint: '[note <dépôt> <texte>]',
159 noteSet: (name: string, text: string) => `Note sur ${name} : ${text}`,
160 noteRemoved: (name: string) => `Note de ${name} retirée.`,
161 paneTitle: 'Où on en est',
162 noRepo: 'Aucun dépôt trouvé : vérifie « projectsRoot » (/config).',
163 widen: '(élargis le terminal pour voir le panneau)',
164 refreshing: 'mise à jour…',
165 checkedAt: (hm: string) => `vérifié à ${hm}`,
166 localOnly: 'local seulement',
167 refresh: 'Rafraîchir',
168 noRepoPane: 'Aucun dépôt trouvé. Règle « projectsRoot » ou « extraRepos » dans /config.',
169 server: 'Serveur',
170 pages: 'Pages',
171 setVpsRepos: 'Indique les dépôts du serveur dans « vpsRepos » (/config), ex. api=/srv/api.',
172 changedOnServer: (n: number) => `✎ ${n} ${plural(n, 'modifié')} sur le serveur`,
173 statusFailed: 'git status impossible',
174 },
175 en: {
176 ciRed: (n: number) => `PR #${n} CI failing`,
177 ciPending: (n: number) => `PR #${n} CI running`,
178 behind: (n: number) => `↓${n} to pull`,
179 ahead: (n: number) => `↑${n} unpushed`,
180 neverPushed: 'branch never pushed',
181 dirty: (n: number) => `✎ ${n} ${plural(n, 'file')}`,
182 outsideMain: (n: number, main: string) => `${n} ${plural(n, 'commit')} not in ${main}`,
183 clean: 'clean',
184 sumRed: (n: number) => `${n} CI failing`,
185 sumUnpushed: (n: number) => `${n} ${plural(n, 'repo')} unpushed`,
186 sumDirty: (n: number) => `${n} with changes`,
187 sumServer: (n: number) => `server ≠ main ×${n}`,
188 allClean: 'repos clean',
189 relBehind: (main: string) => `behind ${main}`,
190 relAhead: (main: string) => `ahead of ${main}`,
191 unreachable: 'unreachable',
192 relUnknown: 'commit unknown here',
193 commandDesc: 'State of your repos and server (“note <repo> <text>” to note what you are waiting on)',
194 commandHint: '[note <repo> <text>]',
195 noteSet: (name: string, text: string) => `Note on ${name}: ${text}`,
196 noteRemoved: (name: string) => `Note on ${name} removed.`,
197 paneTitle: 'Where things stand',
198 noRepo: 'No repo found: check “projectsRoot” (/config).',
199 widen: '(widen the terminal to see the pane)',
200 refreshing: 'updating…',
201 checkedAt: (hm: string) => `checked at ${hm}`,
202 localOnly: 'local only',
203 refresh: 'Refresh',
204 noRepoPane: 'No repo found. Set “projectsRoot” or “extraRepos” in /config.',
205 server: 'Server',
206 pages: 'Pages',
207 setVpsRepos: 'List the server repos in “vpsRepos” (/config), e.g. api=/srv/api.',
208 changedOnServer: (n: number) => `✎ ${n} changed on the server`,
209 statusFailed: 'git status failed',
210 },
211} as const
212
213/** Les textes affichés, dans la langue du réglage `language`. */
214export function messages(lang: Lang) {
215 return MESSAGES[lang]
216}
217types/index.d.ts 56 lines1/** L'état d'un dépôt local, lu avec git (et gh pour la PR). */
2export type RepoState = {
3 name: string
4 path: string
5 branch: string
6 /** Fichiers modifiés ou nouveaux, non commités. */
7 dirty: number
8 /** Commits locaux pas encore poussés sur la branche distante. */
9 ahead: number
10 /** Commits distants pas encore récupérés. */
11 behind: number
12 /** Vrai si la branche n'a pas de branche distante. */
13 noUpstream: boolean
14 /** Commits de la branche absents de la branche principale distante. */
15 aheadOfMain: number
16 /** Nom de la branche principale (main ou master). */
17 mainBranch: string
18 /** SHA court de origin/<main>, pour comparer avec le VPS. */
19 mainSha: string | null
20 pr: PullRequest | null
21 /** Note libre (« attend la relecture »), gardée entre les sessions. */
22 note: string | null
23 error?: string
24}
25
26export type PullRequest = {
27 number: number
28 state: string
29 /** pass, fail, pending, ou none s'il n'y a pas de contrôle. */
30 checks: 'pass' | 'fail' | 'pending' | 'none'
31}
32
33/** Ce qui tourne sur le VPS pour un dépôt. */
34export type Deployed = {
35 name: string
36 sha: string | null
37 /** égal à main, en retard, en avance (ou inconnu localement), injoignable. */
38 relation: 'equal' | 'behind' | 'ahead' | 'unknown' | 'unreachable'
39 /** Modifs non commitées sur le VPS. */
40 dirty: number
41}
42
43export type Health = { url: string; code: number | null }
44
45declare module 'claude-code' {
46 interface PluginState {
47 'ou-on-en-est': {
48 repos: RepoState[]
49 deployed: Deployed[]
50 health: Health[]
51 checkedAt: number | null
52 isRefreshing: boolean
53 }
54 }
55}
56