SLOPSHOPPER

ou-on-en-est

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…

newpanecommandstatusprocesstimer
★ 2v0.2.0MITupdated 2026-10-07DarkSawOktay/claude-mods/ou-on-en-est
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ou-on-en-est
│ ┃ Où on en est ✕ › fix the failing auth test and add an audit log call │ ┃ vérifié à 08:53 [ Rafraîchir ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Aucun dépôt trouvé. Règle « projectsRoot » ⎿ Read 6 lines │ ┃ ou « extraRepos » dans /config. ⏺ 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 │ │ › /ou-on-en-est │ ⎿ ou-on-en-est: Aucun dépôt trouvé : vérifie « projectsRoot » (/co │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Où on en est
vérifié à 08:53 [ Rafraîchir ] Aucun dépôt trouvé. Règle « projectsRoot » ou « extraRepos » dans /config.
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 287 lines
1// 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}
287
hooks/logic.ts 217 lines
1// 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}
217
types/index.d.ts 56 lines
1/** 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