SLOPSHOPPER

flash-veille

Le flash info du dev au-dessus du prompt : les actus fraîches de Human Coders News, du Journal du hacker, de la veille de Camille Roux, de LinuxFr et des blogs…

newpanebandcommandnetworktimer
★ 5v0.4.0MITupdated 2026-10-01camilleroux/flash-veille/plugins/flash-veille
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flash-veille
│ ┃ flash-veille ✕ › fix the failing auth test and add an audit log call │ ┃ [ Tout ] [ HC ] [ JdH ] [ CR ] [ LFR ] [ ANT │ ┃ Mis à jour il y a 0min · injoignable : Human ⏺ Read(src/auth.ts) │ ┃ Rien pour l'instant. ⎿ 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 │ │ › /veille │ ⎿ flash-veille: Flash veille affiché au-dessus du prompt. /veille │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · flash-veille
[ Tout ] [ HC ] [ JdH ] [ CR ] [ LFR ] [ ANT ] [ CDV ] [ CC Mis à jour il y a 0min · injoignable : Human Coders New… Rien pour l'instant.
README

Flash veille

Le flash info du dev, au-dessus du prompt de Claude Code.

Pendant que vous codez, une ligne discrète fait défiler les actus fraîches de la tech francophone et des labos d'IA, une à la fois :

Le bandeau Flash veille au-dessus du prompt de Claude Code

Chaque titre mène là où l'actu a été partagée : la page de l'actu sur Human Coders News ou sur le Journal du hacker, et pour la veille de Camille Roux, son post sur Bluesky.

Flash veille est un mod Claude Code (un plugin à hooks de fonction) : il demande Claude Code 2.1.287 ou plus récent. L'API des mods est en accès anticipé et peut encore changer.

Installation

/plugin marketplace add camilleroux/flash-veille
/plugin install flash-veille@flash-veille

Le bandeau apparaît au-dessus du prompt dès la session suivante.

Commandes

CommandeEffet
/veilleAffiche le bandeau et rafraîchit les actus
/veille toutOuvre la liste complète dans un panneau, filtrable par source
/veille offMasque le bandeau (et le panneau) ; mémorisé d'une session à l'autre
/veille sourcesListe les sources suivies
/veille ajouter <site>Suit un autre site : /veille ajouter korben.info trouve son flux tout seul
/veille retirer <site>Ne plus suivre un site ajouté avec /veille ajouter : /veille retirer korben (son code kor ou korben.info marchent aussi)

Le bandeau se replie aussi avec ctrl+x ctrl+a.

Options

Dans /config, sous flash-veille :

OptionPar défautEffet
Sourceshc, jdh, camille, linuxfr, anthropic, claudedev, claudecodeLes sources intégrées à suivre
Rotation (secondes)12Durée d'affichage de chaque actu
Mots-clés(vide)Ex. IA, Rails, sécurité : les actus qui en parlent passent en premier, marquées ★ (casse et accents ignorés)
Seulement les mots-clésnonN'affiche que ces actus

Sources intégrées

CodeSourcePar défaut
hcHuman Coders News✓
jdhJournal du hacker✓
camilleLa veille de Camille Roux, publiée sur Bluesky✓
linuxfrDépêches LinuxFr.org✓
anthropicAnthropic News ¹✓
claudedevBlog claude.dev✓
claudecodeReleases de Claude Code✓
korbenKorben
openaiOpenAI News
deepmindGoogle DeepMind
huggingfaceHugging Face
simonwSimon Willison

¹ anthropic.com n'a pas de flux RSS officiel : Flash veille lit le miroir communautaire Olshansk/rss-feeds.

Respect des sites sources

Les flux sont récupérés au plus tous les quarts d'heure, et ce cache est partagé entre toutes vos sessions Claude Code : ouvrir dix sessions ne fait pas dix fois plus de requêtes. Chaque requête s'annonce avec un User-Agent qui renvoie vers ce dépôt.

Ce que fait le mod : réseau, données, hooks

Réseau. Le mod ne fait que des requêtes HTTPS GET, en lecture seule, au plus toutes les 15 minutes (cache partagé entre les sessions), vers :

  • les flux des sources intégrées activées, et seulement elles : news.humancoders.com, www.journalduhacker.net, bsky.app, linuxfr.org, raw.githubusercontent.com (miroir Anthropic), claude.dev, github.com (releases de Claude Code), et sur option korben.info, openai.com, deepmind.google, huggingface.co, simonwillison.net ;
  • le site que vous donnez vous-même à /veille ajouter <site> : sa page d'accueil pour y trouver le flux annoncé, puis au besoin les adresses de flux habituelles du même site (/feed, /rss, /feed.xml, /rss.xml, /atom.xml, /index.xml, /feed/), et ensuite son flux.

L'adresse de chaque requête vient de cette liste ou de ce que vous avez tapé : c'est pourquoi elle n'est pas écrite en dur à l'appel.

Données envoyées. Aucune donnée locale ne sort : ni fichier, ni code, ni conversation, ni identifiant. Une requête ne porte que l'adresse du flux et un User-Agent fixe (flash-veille/0.4 (Claude Code mod; +https://github.com/camilleroux/flash-veille)) ; pas de cookie, pas d'authentification, pas de corps de requête.

Données gardées. Dans le stockage local que Claude Code donne au plugin : le cache des actus, la liste des sites ajoutés et l'état masqué du bandeau. Rien d'autre.

Commandes et outils. Le mod n'exécute aucune commande, aucun outil, aucun agent, et ne lit aucun fichier. Le contenu des flux n'est jamais interprété comme une instruction : ce n'est que du texte affiché (un titre et un lien).

Hooks.

  • session.start : enregistre la commande /veille, charge le cache et lance deux minuteurs (récupération des flux toutes les 15 minutes, rotation du bandeau).
  • command.run : ne reçoit que /veille (le hook ne s'abonne qu'à cette commande) ; les autres commandes ne passent pas par lui.
  • ui.render : dessine le bandeau au-dessus du prompt (AbovePrompt) et le panneau du mod (Pane ouvert par /veille tout) ; il ne modifie aucun autre affichage, et laisse la place à Claude Code dès qu'un sondage occupe le bandeau.

Le détail est dans la politique de confidentialité.

In English. Flash veille only sends read-only HTTPS GET requests, at most every 15 minutes, to the RSS/Atom feeds of the enabled sources listed above and to the site you pass to /veille ajouter. It sends no local data (no files, code, conversation or credentials), only the feed URL and a fixed User-Agent. It runs no commands, tools or agents and reads no files. It hooks session.start (registers /veille, starts the timers), command.run (only /veille) and ui.render (its own band above the prompt and its own pane).

Développement

claude plugin validate .
claude plugin test .

Pour essayer vos modifications : claude --plugin-dir ..

Licence

MIT, par Camille Roux.

Source 5 files
hooks/register.tsx 377 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CustomSource, Filter, Item, SourceId } from '../types'
5import { discover } from './discover'
6import type { Source } from './feeds'
7import {
8  SOURCES,
9  ago,
10  byKeywords,
11  columns,
12  fit,
13  matchesKeywords,
14  merge,
15  parseFeed,
16  parseKeywords,
17  pickSources,
18  safeHref,
19} from './feeds'
20import { revealFrame } from './transition'
21
22const PANE = 'flash-veille'
23const TITLE = 'Flash veille'
24const DEFAULT_SOURCES = 'hc, jdh, camille, linuxfr, anthropic, claudedev, claudecode'
25const REFRESH_MS = 15 * 60_000
26// Un cache « presque périmé » est rafraîchi, pour que le minuteur ne le rate pas d'un cheveu
27const CACHE_MARGIN_MS = 60_000
28const USER_AGENT = 'flash-veille/0.4 (Claude Code mod; +https://github.com/camilleroux/flash-veille)'
29const FRESH_MS = 24 * 3600_000
30const MAX_SHOWN = 60
31const CACHE_KEY = 'items'
32const HIDDEN_KEY = 'isBandHidden'
33const CUSTOM_KEY = 'customSources'
34const TICKER_POOL = 15
35const MIN_ROTATE_S = 3
36const DEFAULT_ROTATE_S = 12
37const FRAMES = 12
38const FRAME_MS = 60
39const LABEL = '📰 Veille'
40const STAR = '★ '
41const ENGINE_CONTROL = 5
42const USAGE = '/veille [tout | off | sources | ajouter <site> | retirer <site>]'
43
44const items = atom({ plugin: 'flash-veille', key: 'items' } as const, [])
45const fetchedAt = atom({ plugin: 'flash-veille', key: 'fetchedAt' } as const, 0)
46const filter = atom({ plugin: 'flash-veille', key: 'filter' } as const, 'all')
47const isLoading = atom({ plugin: 'flash-veille', key: 'isLoading' } as const, false)
48const errors = atom({ plugin: 'flash-veille', key: 'errors' } as const, [])
49const index = atom({ plugin: 'flash-veille', key: 'index' } as const, 0)
50const frame = atom({ plugin: 'flash-veille', key: 'frame' } as const, FRAMES)
51const isBandHidden = atom({ plugin: 'flash-veille', key: 'isBandHidden' } as const, false)
52const customSources = atom({ plugin: 'flash-veille', key: 'customSources' } as const, [])
53
54type Cache = { items?: Item[]; fetchedAt?: number; sourceIds?: string[] }
55
56// Le cache est partagé entre les sessions : tant qu'il est frais et couvre les sources
57// suivies, aucune session ne retélécharge les flux (sauf demande explicite)
58async function refresh($: EngineInterface, sources: Source[], isForced = false): Promise<void> {
59  if (await read($, isLoading)) {
60    return
61  }
62  const now = await $.clock.now()
63  if (!isForced) {
64    const cached = (await $.store.get(CACHE_KEY)) as Cache | undefined
65    const isFresh = cached?.fetchedAt !== undefined && now - cached.fetchedAt < REFRESH_MS - CACHE_MARGIN_MS
66    if (isFresh && cached.items && sources.every(s => cached.sourceIds?.includes(s.id))) {
67      await update($, items, () => cached.items ?? [])
68      await update($, fetchedAt, () => cached.fetchedAt ?? now)
69      return
70    }
71  }
72  await update($, isLoading, () => true)
73  const failed: string[] = []
74  const lists = await Promise.all(
75    sources.map(async source => {
76      try {
77        const res = await $.http.fetch(source.url, { headers: { 'user-agent': USER_AGENT } })
78        if (!res.ok) {
79          failed.push(`${source.name} (HTTP ${res.status})`)
80          return []
81        }
82        return parseFeed(res.text, source.id)
83      } catch {
84        failed.push(source.name)
85        return []
86      }
87    }),
88  )
89  // Une source en panne garde ses anciens items plutôt que de disparaître
90  const previous = await read($, items)
91  const kept = sources.flatMap((source, i) => {
92    const fresh = lists[i] ?? []
93    return fresh.length ? fresh : previous.filter(it => it.source === source.id)
94  })
95  const merged = merge([kept])
96  await update($, items, () => merged)
97  await update($, fetchedAt, () => now)
98  await update($, errors, () => failed)
99  await update($, isLoading, () => false)
100  const cache: Cache = { items: merged, fetchedAt: now, sourceIds: sources.map(s => s.id) }
101  await $.store.set(CACHE_KEY, cache)
102}
103
104// Les images de la transition, une par FRAME_MS, jusqu'au titre en entier
105function animate($: EngineInterface, next: number): void {
106  $.clock.after(FRAME_MS, () => {
107    void update($, frame, () => next).then(() => {
108      if (next < FRAMES) {
109        animate($, next + 1)
110      }
111    })
112  })
113}
114
115// Masquer le bandeau vaut aussi pour les sessions suivantes
116async function setBandHidden($: EngineInterface, hidden: boolean): Promise<void> {
117  await update($, isBandHidden, () => hidden)
118  await $.store.set(HIDDEN_KEY, hidden)
119}
120
121// Les sources suivies : celles des options, puis celles ajoutées avec /veille ajouter
122async function activeSources($: EngineInterface, builtins: Source[]): Promise<Source[]> {
123  return [...builtins, ...(await read($, customSources))]
124}
125
126async function setCustomSources($: EngineInterface, list: CustomSource[]): Promise<void> {
127  await update($, customSources, () => list)
128  await $.store.set(CUSTOM_KEY, list)
129}
130
131export const register: Register = (on, options) => {
132  // Les options du plugin (menu de config) : un changement recharge le module
133  const picked = pickSources(String(options.sources ?? DEFAULT_SOURCES))
134  const builtins = picked.length ? picked : pickSources(DEFAULT_SOURCES)
135  const rotateMs = Math.max(MIN_ROTATE_S, Number(options.rotationSeconds) || DEFAULT_ROTATE_S) * 1000
136  const keywords = parseKeywords(String(options.keywords ?? ''))
137  const isKeywordsOnly = options.keywordsOnly === true
138
139  const sourceOf = (id: SourceId, active: Source[]) => active.find(s => s.id === id) ?? SOURCES.find(s => s.id === id)
140
141  // Ce qui s'affiche : les sources actives, les mots-clés d'abord (ou seuls)
142  const visible = (all: Item[], active: Source[]) =>
143    byKeywords(
144      all.filter(item => active.some(s => s.id === item.source)),
145      keywords,
146      isKeywordsOnly,
147    )
148  const star = (item: Item) => (keywords.length && matchesKeywords(item.title, keywords) ? STAR : '')
149
150  on('session.start', async ($, e, next) => {
151    await $.command.register({
152      name: 'veille',
153      description: 'Flash veille : les actus fraîches au-dessus du prompt',
154      argumentHint: '[tout|off|sources|ajouter <site>|retirer <site>]',
155    })
156    const wasHidden = (await $.store.get(HIDDEN_KEY)) === true
157    await update($, isBandHidden, () => wasHidden)
158    const custom = (await $.store.get(CUSTOM_KEY)) as CustomSource[] | undefined
159    await update($, customSources, () => custom ?? [])
160
161    // Le cache de la session précédente s'affiche tout de suite
162    if ((await read($, items)).length === 0) {
163      const cached = (await $.store.get(CACHE_KEY)) as { items?: Item[]; fetchedAt?: number } | undefined
164      if (cached?.items?.length) {
165        await update($, items, () => cached.items ?? [])
166        await update($, fetchedAt, () => cached.fetchedAt ?? 0)
167      }
168    }
169    await update($, isLoading, () => false)
170
171    void activeSources($, builtins).then(sources => refresh($, sources))
172    $.clock.every(REFRESH_MS, () => void activeSources($, builtins).then(sources => refresh($, sources)))
173    // Le bandeau passe à l'actu suivante, sauf quand il est masqué
174    $.clock.every(rotateMs, async () => {
175      if (!(await read($, isBandHidden))) {
176        await update($, index, n => n + 1)
177        await update($, frame, () => 0)
178        animate($, 1)
179      }
180    })
181
182    return next(e)
183  })
184
185  // Tout passe par la commande : les clics et les touches n'atteignent pas le bandeau partout
186  on('command.run', { command: 'veille' }, async ($, e) => {
187    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
188    const arg = rest.join(' ')
189
190    switch (verb.toLowerCase()) {
191      case 'off': {
192        await setBandHidden($, true)
193        await $.ui.close({ id: PANE })
194
195        return { text: 'Flash veille masqué (/veille pour le réafficher).' }
196      }
197      case 'tout': {
198        await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
199        void refresh($, await activeSources($, builtins))
200
201        return { text: 'Pane « Flash veille » ouvert (/veille off pour le fermer).' }
202      }
203      case 'sources': {
204        const custom = await read($, customSources)
205        const lines = [
206          ...builtins.map(s => `${s.short.padEnd(4)}${s.name}`),
207          ...custom.map(s => `${s.short.padEnd(4)}${s.name} (ajoutée : /veille retirer ${s.id.replace(/^perso-/, '').split('.')[0]})`),
208        ]
209
210        return {
211          text: [
212            'Sources suivies :',
213            ...lines.map(line => `  ${line}`),
214            '',
215            'Ajouter un site : /veille ajouter korben.info',
216            `Sources intégrées disponibles : ${SOURCES.map(s => s.id).join(', ')} (option « Sources » de /config)`,
217          ].join('\n'),
218        }
219      }
220      case 'ajouter': {
221        if (!arg) {
222          return { text: 'Usage : /veille ajouter <adresse du site ou du flux>, par exemple /veille ajouter korben.info' }
223        }
224        const custom = await read($, customSources)
225        const found = await discover(
226          arg,
227          async url => {
228            const res = await $.http.fetch(url, { headers: { 'user-agent': USER_AGENT } })
229            return { ok: res.ok, text: res.text }
230          },
231          custom.length,
232        )
233        if ('error' in found) {
234          return { text: found.error }
235        }
236        const active = await activeSources($, builtins)
237        if (active.some(s => s.url === found.url || s.id === found.id)) {
238          return { text: `${found.name} est déjà suivi.` }
239        }
240        // Deux sources ne partagent pas un code : KOR, KOR2…
241        const codes = new Set(active.map(s => s.short))
242        let short = found.short
243        for (let n = 2; codes.has(short); n++) {
244          short = `${found.short.slice(0, 2)}${n}`
245        }
246        const source = { ...found, short }
247        await setCustomSources($, [...custom, source])
248        await setBandHidden($, false)
249        void refresh($, [...active, source], true)
250
251        return { text: `Ajouté : ${source.name} (${source.short}), ${source.url}` }
252      }
253      case 'retirer': {
254        const custom = await read($, customSources)
255        // Le code (kor), le site (korben, korben.info), l'adresse du flux, ou un bout du nom s'il est sans ambiguïté
256        const wanted = arg.toLowerCase().replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')
257        const names = (s: CustomSource) => {
258          const host = s.id.replace(/^perso-/, '')
259          return [s.short.toLowerCase(), host, host.split('.')[0], s.url.toLowerCase().replace(/^https?:\/\/(www\.)?/, '')]
260        }
261        const inName = custom.filter(s => wanted.length >= 3 && s.name.toLowerCase().includes(wanted))
262        const gone = custom.find(s => names(s).includes(wanted)) ?? (inName.length === 1 ? inName[0] : undefined)
263        if (!gone) {
264          return { text: `Aucune source ajoutée ne correspond à « ${arg} » (voir /veille sources).` }
265        }
266        await setCustomSources(
267          $,
268          custom.filter(s => s !== gone),
269        )
270        await update($, items, list => list.filter(it => it.source !== gone.id))
271
272        return { text: `Retiré : ${gone.name}.` }
273      }
274      case '': {
275        await setBandHidden($, false)
276        void refresh($, await activeSources($, builtins))
277
278        return { text: `Flash veille affiché au-dessus du prompt. ${USAGE}` }
279      }
280      default:
281        return { text: `Commande inconnue. ${USAGE}` }
282    }
283  })
284
285  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286    const active = await activeSources($, builtins)
287    const all = visible(await read($, items), active).slice(0, TICKER_POOL)
288    if (e.props.hasSurvey || all.length === 0 || (await read($, isBandHidden))) {
289      return next(e)
290    }
291
292    const { Box, Text, Link } = $.ui.resolve(e)
293    const now = await $.clock.now()
294    const position = (await read($, index)) % all.length
295    const item = all[position]!
296    const source = sourceOf(item.source, active)!
297    const href = safeHref(item.discussUrl ?? item.url)
298    const age = ago(item.at, now)
299    const counter = `${position + 1}/${all.length}`
300    // Le titre est coupé ici, en colonnes réelles : la ligne ne passe jamais à la ligne.
301    // ENGINE_CONTROL : le « [-] » que Claude Code dessine par-dessus le bout du bandeau ; on lui laisse sa place
302    const fixed = columns(LABEL) + source.short.length + age.length + counter.length + 4 + ENGINE_CONTROL
303    const title = revealFrame(fit(star(item) + item.title, e.props.bodyColumns - fixed), await read($, frame), FRAMES)
304
305    return (
306      <Box width={e.props.bodyColumns - ENGINE_CONTROL} height={1} overflow="hidden" columnGap={1}>
307        <Text dimColor>{LABEL}</Text>
308        <Text color={source.color} bold>
309          {source.short}
310        </Text>
311        <Text dimColor>{age}</Text>
312        <Box flexGrow={1} flexShrink={1}>
313          <Text wrap="truncate-end">{href ? <Link href={href}>{title}</Link> : title}</Text>
314        </Box>
315        <Text dimColor>{counter}</Text>
316      </Box>
317    )
318  })
319
320  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
321    const { Box, Text, Button, Link } = $.ui.resolve(e)
322    const active = await activeSources($, builtins)
323    const all = visible(await read($, items), active)
324    const current: Filter = await read($, filter)
325    const loading = await read($, isLoading)
326    const failed = await read($, errors)
327    const at = await read($, fetchedAt)
328    const now = await $.clock.now()
329
330    const shown = current === 'all' ? all : all.filter(it => it.source === current)
331    const width = e.props.bodyColumns
332    const tabs: { id: Filter; label: string }[] = [
333      { id: 'all', label: 'Tout' },
334      ...active.map(s => ({ id: s.id, label: s.short })),
335    ]
336
337    return (
338      <Box flexDirection="column" width={width}>
339        <Box flexWrap="wrap" columnGap={1}>
340          {tabs.map(tab => (
341            <Button
342              key={`tab-${tab.id}`}
343              label={tab.label}
344              variant={tab.id === current ? 'primary' : undefined}
345              onPress={() => update($, filter, () => tab.id)}
346            />
347          ))}
348          <Button key="refresh" label={loading ? '…' : '↻'} hotkey="r" onPress={() => refresh($, active, true)} />
349          <Button key="close" label="fermer" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
350        </Box>
351        <Text dimColor wrap="truncate-end">
352          {loading ? 'Mise à jour…' : at ? `Mis à jour il y a ${ago(at, now)}` : 'Chargement…'}
353          {failed.length ? ` · injoignable : ${failed.join(', ')}` : ''}
354        </Text>
355        {shown.length === 0 && !loading && <Text dimColor>Rien pour l'instant.</Text>}
356        {shown.slice(0, MAX_SHOWN).map(item => {
357          const source = sourceOf(item.source, active)!
358          const href = safeHref(item.discussUrl ?? item.url)
359          const isFresh = item.at > 0 && now - item.at < FRESH_MS
360          const title = star(item) + item.title
361          return (
362            <Box key={item.id} columnGap={1}>
363              <Text color={source.color}>{source.short.padEnd(3)}</Text>
364              <Text dimColor>{ago(item.at, now).padStart(4)}</Text>
365              <Box flexShrink={1}>
366                <Text wrap="truncate-end" dimColor={!isFresh} bold={isFresh}>
367                  {href ? <Link href={href}>{title}</Link> : title}
368                </Text>
369              </Box>
370            </Box>
371          )
372        })}
373      </Box>
374    )
375  })
376}
377
hooks/discover.ts 120 lines
1// `/veille ajouter korben.info` : trouver le flux RSS/Atom d'un site à partir
2// de son adresse, comme le ferait un lecteur de flux.
3
4import type { CustomSource } from '../types'
5import { decode } from './feeds'
6
7export type FetchText = (url: string) => Promise<{ ok: boolean; text: string }>
8
9// Les adresses de flux les plus courantes, quand la page n'annonce pas le sien
10const GUESSES = ['/feed', '/rss', '/feed.xml', '/rss.xml', '/atom.xml', '/index.xml', '/feed/']
11
12const COLORS = ['cyan', 'magenta', 'green', 'yellow', 'blue', 'red']
13
14export function isFeed(text: string): boolean {
15  return /<rss[\s>]|<feed[\s>]|<rdf:RDF[\s>]/i.test(text.slice(0, 2000))
16}
17
18export function normalizeUrl(input: string): string | undefined {
19  const raw = input.trim()
20  try {
21    const url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`)
22    return url.hostname.includes('.') ? url.href : undefined
23  } catch {
24    return undefined
25  }
26}
27
28// Un site annonce parfois plusieurs flux : le principal passe avant podcasts et commentaires
29const SIDE_FEED = /comment|podcast|mp3|audio|video|episode/i
30
31// <link rel="alternate" type="application/rss+xml" href="…">, attributs dans n'importe quel ordre
32export function announcedFeed(html: string, base: string): string | undefined {
33  const feeds: string[] = []
34  for (const [tag] of html.matchAll(/<link\b[^>]*>/gi)) {
35    if (/rel=["']?alternate/i.test(tag) && /type=["']?application\/(rss|atom)\+xml/i.test(tag)) {
36      const href = tag.match(/href=["']([^"']+)["']/i)?.[1]
37      if (href) {
38        feeds.push(new URL(decode(href), base).href)
39      }
40    }
41  }
42  return feeds.find(url => !SIDE_FEED.test(url)) ?? feeds[0]
43}
44
45function feedTitle(xml: string): string | undefined {
46  const raw = xml.match(/<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i)?.[1]
47  return raw
48    ? decode(raw.replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, ''))
49        .replace(/\s+/g, ' ')
50        .trim()
51    : undefined
52}
53
54// "korben.info" → "KOR", "www.journalduhacker.net" → "JOU"
55export function shortFor(url: string): string {
56  const host = new URL(url).hostname.replace(/^www\./, '')
57  return host.slice(0, 3).toUpperCase()
58}
59
60export async function discover(
61  input: string,
62  fetchText: FetchText,
63  taken: number,
64): Promise<CustomSource | { error: string }> {
65  const start = normalizeUrl(input)
66  if (!start) {
67    return { error: `« ${input} » n'est pas une adresse de site.` }
68  }
69
70  const found = async (url: string): Promise<string | undefined> => {
71    try {
72      const res = await fetchText(url)
73      return res.ok && isFeed(res.text) ? res.text : undefined
74    } catch {
75      return undefined
76    }
77  }
78
79  let feedUrl: string | undefined
80  let xml: string | undefined
81  let announced: string | undefined
82  try {
83    const page = await fetchText(start)
84    if (page.ok && isFeed(page.text)) {
85      feedUrl = start
86      xml = page.text
87    } else if (page.ok) {
88      announced = announcedFeed(page.text, start)
89    }
90  } catch {
91    // le site ne répond pas sur cette adresse : on tente les adresses habituelles
92  }
93  // Le flux annoncé d'abord ; s'il n'est qu'annexe (podcast…), les adresses habituelles avant lui
94  const isSide = announced !== undefined && SIDE_FEED.test(announced)
95  const candidates = [
96    ...(announced && !isSide ? [announced] : []),
97    ...GUESSES.map(guess => new URL(guess, start).href),
98    ...(announced && isSide ? [announced] : []),
99  ]
100  for (const url of candidates) {
101    if (xml) {
102      break
103    }
104    xml = await found(url)
105    feedUrl = xml ? url : undefined
106  }
107  if (!xml || !feedUrl) {
108    return { error: `Aucun flux RSS ou Atom trouvé sur ${start}` }
109  }
110
111  const host = new URL(feedUrl).hostname.replace(/^www\./, '')
112  return {
113    id: `perso-${host}`,
114    name: feedTitle(xml) || host,
115    short: shortFor(feedUrl),
116    color: COLORS[taken % COLORS.length]!,
117    url: feedUrl,
118  }
119}
120
hooks/feeds.ts 359 lines
1import type { Item, SourceId } from '../types'
2
3export type Source = {
4  id: SourceId
5  name: string
6  short: string
7  color: string
8  url: string
9}
10
11export const SOURCES: Source[] = [
12  {
13    id: 'hc',
14    name: 'Human Coders News',
15    short: 'HC',
16    color: 'magenta',
17    url: 'https://news.humancoders.com/items/feed.rss',
18  },
19  {
20    id: 'jdh',
21    name: 'Journal du hacker',
22    short: 'JdH',
23    color: 'yellow',
24    url: 'https://www.journalduhacker.net/rss',
25  },
26  {
27    // La veille quotidienne de Camille, telle que publiée sur Bluesky
28    id: 'camille',
29    name: 'Camille Roux',
30    short: 'CR',
31    color: 'cyan',
32    url: 'https://bsky.app/profile/camilleroux.com/rss',
33  },
34  {
35    id: 'linuxfr',
36    name: 'Dépêches LinuxFr.org',
37    short: 'LFR',
38    color: 'blueBright',
39    url: 'https://linuxfr.org/news.atom',
40  },
41  {
42    id: 'korben',
43    name: 'Korben',
44    short: 'KOR',
45    color: 'greenBright',
46    url: 'https://korben.info/feed',
47  },
48  {
49    // anthropic.com/news n'a pas de flux officiel : miroir communautaire (github.com/Olshansk/rss-feeds)
50    id: 'anthropic',
51    name: 'Anthropic News',
52    short: 'ANT',
53    color: 'redBright',
54    url: 'https://raw.githubusercontent.com/Olshansk/rss-feeds/main/feeds/feed_anthropic_news.xml',
55  },
56  {
57    id: 'claudedev',
58    name: 'Blog claude.dev',
59    short: 'CDV',
60    color: 'red',
61    url: 'https://claude.dev/rss.xml',
62  },
63  {
64    id: 'claudecode',
65    name: 'Releases Claude Code',
66    short: 'CC',
67    color: 'yellowBright',
68    url: 'https://github.com/anthropics/claude-code/releases.atom',
69  },
70  {
71    id: 'openai',
72    name: 'OpenAI News',
73    short: 'OAI',
74    color: 'green',
75    url: 'https://openai.com/news/rss.xml',
76  },
77  {
78    id: 'deepmind',
79    name: 'Google DeepMind',
80    short: 'GDM',
81    color: 'blue',
82    url: 'https://deepmind.google/blog/rss.xml',
83  },
84  {
85    id: 'huggingface',
86    name: 'Hugging Face',
87    short: 'HF',
88    color: 'yellow',
89    url: 'https://huggingface.co/blog/feed.xml',
90  },
91  {
92    id: 'simonw',
93    name: 'Simon Willison',
94    short: 'SW',
95    color: 'whiteBright',
96    url: 'https://simonwillison.net/atom/everything/',
97  },
98]
99
100// "hc, JdH ,anthropic" → les sources connues, dans l'ordre de SOURCES ; vide ou inconnu → aucune
101export function pickSources(list: string): Source[] {
102  const wanted = new Set(
103    list
104      .split(',')
105      .map(id => id.trim().toLowerCase())
106      .filter(Boolean),
107  )
108  return SOURCES.filter(source => wanted.has(source.id))
109}
110
111export function parseKeywords(list: string): string[] {
112  return list
113    .split(',')
114    .map(word => word.trim().toLowerCase())
115    .filter(Boolean)
116}
117
118// Sans accents ni casse : « sécurité » trouve « Securite »
119function fold(text: string): string {
120  return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
121}
122
123export function matchesKeywords(title: string, keywords: string[]): boolean {
124  const haystack = fold(title)
125  return keywords.some(word => new RegExp(`(^|[^\\p{L}\\p{N}])${escape(fold(word))}($|[^\\p{L}\\p{N}])`, 'u').test(haystack))
126}
127
128function escape(text: string): string {
129  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
130}
131
132// Les actus qui parlent des mots-clés d'abord (ou seules), les autres ensuite, chacune par fraîcheur
133export function byKeywords(items: Item[], keywords: string[], isOnly: boolean): Item[] {
134  if (keywords.length === 0) {
135    return items
136  }
137  const hits = items.filter(item => matchesKeywords(item.title, keywords))
138  return isOnly ? hits : [...hits, ...items.filter(item => !hits.includes(item))]
139}
140
141const ENTITIES: Record<string, string> = {
142  amp: '&',
143  lt: '<',
144  gt: '>',
145  quot: '"',
146  apos: "'",
147  nbsp: ' ',
148}
149
150export function decode(text: string): string {
151  return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
152    if (code[0] === '#') {
153      const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
154      return Number.isFinite(n) ? String.fromCodePoint(n) : whole
155    }
156    return ENTITIES[code.toLowerCase()] ?? whole
157  })
158}
159
160function tag(block: string, name: string): string | undefined {
161  const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i'))
162  if (!match) {
163    return undefined
164  }
165  const raw = (match[1] ?? '').trim()
166  const cdata = raw.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/)
167  return cdata ? (cdata[1] ?? '') : decode(raw)
168}
169
170const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
171
172// RFC 822 ("Thu, 01 Oct 2026 16:50:00 +0000", "01 Oct 2026 18:00 +0200")
173export function parseDate(text: string | undefined): number {
174  const m = text?.match(/(\d{1,2})\s+([a-z]{3})[a-z]*\s+(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([+-]\d{4}|[a-z]+)?/i)
175  if (!m) {
176    return 0
177  }
178  const [, day = '', mon = '', year = '', hour = '', minute = '', second = '0', zone = ''] = m
179  const month = MONTHS.indexOf(mon.toLowerCase())
180  if (month < 0) {
181    return 0
182  }
183  const utc = Date.UTC(+year, month, +day, +hour, +minute, +second)
184  if (/^[+-]\d{4}$/.test(zone)) {
185    const sign = zone[0] === '-' ? -1 : 1
186    const minutes = +zone.slice(1, 3) * 60 + +zone.slice(3, 5)
187    return utc - sign * minutes * 60_000
188  }
189  return utc
190}
191
192// ISO 8601 des flux Atom ("2026-10-01T18:43:19Z", "2026-10-01T06:29:01+00:00")
193export function parseIsoDate(text: string | undefined): number {
194  const m = text?.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?/)
195  if (!m) {
196    return 0
197  }
198  const [, year = '', month = '', day = '', hour = '', minute = '', second = '0', zone = 'Z'] = m
199  const utc = Date.UTC(+year, +month - 1, +day, +hour, +minute, +second)
200  if (zone === 'Z') {
201    return utc
202  }
203  const sign = zone[0] === '-' ? -1 : 1
204  const digits = zone.replace(/[^\d]/g, '')
205  return utc - sign * (+digits.slice(0, 2) * 60 + +digits.slice(2, 4)) * 60_000
206}
207
208function stripHtml(html: string): string {
209  return decode(html.replace(/<[^>]+>/g, ' '))
210}
211
212function oneLine(text: string, max: number): string {
213  const flat = text.replace(/\s+/g, ' ').trim()
214  return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat
215}
216
217// Un post Bluesky : le texte avant le lien devient le titre, le lien l'URL
218function fromPost(description: string): { title: string; url?: string } {
219  const text = description.replace(/📬[\s\S]*$/u, '').trim()
220  const url = text.match(/https?:\/\/\S+/)?.[0]
221  const title = (url ? text.slice(0, text.indexOf(url)) : text).replace(/⬇️/gu, '')
222  return { title: oneLine(title, 200), url }
223}
224
225export function parseFeed(xml: string, source: SourceId): Item[] {
226  const items: Item[] = []
227  for (const block of xml.match(/<item[\s>][\s\S]*?<\/item>/gi) ?? []) {
228    const link = tag(block, 'link')
229    const at = parseDate(tag(block, 'pubDate')) || parseIsoDate(tag(block, 'dc:date'))
230    const id = tag(block, 'guid') ?? link
231    if (!link || !id) {
232      continue
233    }
234    if (source === 'camille') {
235      const post = fromPost(tag(block, 'description') ?? '')
236      if (!post.title) {
237        continue
238      }
239      items.push({ id: `${source}:${id}`, source, title: post.title, url: post.url ?? link, discussUrl: link, at })
240      continue
241    }
242    const title = tag(block, 'title')
243    if (!title) {
244      continue
245    }
246    items.push({ id: `${source}:${id}`, source, title: oneLine(title, 200), url: link, discussUrl: tag(block, 'comments')?.replace(/#comments$/, ''), at })
247  }
248  for (const block of xml.match(/<entry[\s>][\s\S]*?<\/entry>/gi) ?? []) {
249    const item = fromEntry(block, source)
250    if (item) {
251      items.push(item)
252    }
253  }
254  return items
255}
256
257// Une entrée Atom (Simon Willison, LinuxFr, releases GitHub)
258function fromEntry(block: string, source: SourceId): Item | undefined {
259  const links = [...block.matchAll(/<link\b([^>]*)\/?>/gi)].map(m => m[1] ?? '')
260  const alternate = links.find(attrs => !/rel="(?!alternate)/.test(attrs)) ?? links[0]
261  const link = alternate?.match(/href="([^"]+)"/)?.[1]
262  const raw = tag(block, 'title')
263  if (!link || !raw) {
264    return undefined
265  }
266  const at = parseIsoDate(tag(block, 'published') ?? tag(block, 'updated'))
267  const id = tag(block, 'id') ?? link
268  let title = stripHtml(raw)
269  if (source === 'claudecode') {
270    // "v2.1.287" seul ne dit rien : on y ajoute la première nouveauté de la release
271    const first = (tag(block, 'content') ?? '').match(/<li>([\s\S]*?)<\/li>/i)?.[1]
272    title = `Claude Code ${title}${first ? ` : ${stripHtml(first)}` : ''}`
273  }
274  return { id: `${source}:${id}`, source, title: oneLine(title, 200), url: decode(link), at }
275}
276
277// Fusionne, dédoublonne par URL (la plus ancienne mention gagne), trie du plus frais au plus ancien
278export function merge(lists: Item[][], limit = 150): Item[] {
279  const byUrl = new Map<string, Item>()
280  for (const item of lists.flat()) {
281    const key = item.url.replace(/[?#].*$/, '').replace(/\/$/, '')
282    const known = byUrl.get(key)
283    if (!known || (item.at && item.at < known.at)) {
284      byUrl.set(key, item)
285    }
286  }
287  return [...byUrl.values()].sort((a, b) => b.at - a.at).slice(0, limit)
288}
289
290export function ago(at: number, now: number): string {
291  if (!at) {
292    return '?'
293  }
294  const minutes = Math.max(0, Math.round((now - at) / 60_000))
295  if (minutes < 60) {
296    return `${minutes}min`
297  }
298  const hours = Math.round(minutes / 60)
299  return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}j`
300}
301
302// Colonnes qu'occupe un caractère dans un terminal : 0 (combinant), 1 ou 2 (emoji, CJK)
303function charColumns(cp: number): number {
304  if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f)) {
305    return 0
306  }
307  if (
308    cp >= 0x1f000 ||
309    (cp >= 0x2600 && cp <= 0x27bf) ||
310    (cp >= 0x2b00 && cp <= 0x2bff) ||
311    (cp >= 0x1100 && cp <= 0x115f) ||
312    (cp >= 0x2e80 && cp <= 0xa4cf) ||
313    (cp >= 0xac00 && cp <= 0xd7a3) ||
314    (cp >= 0xf900 && cp <= 0xfaff) ||
315    (cp >= 0xff00 && cp <= 0xff60)
316  ) {
317    return 2
318  }
319  return 1
320}
321
322export function columns(text: string): number {
323  let total = 0
324  for (const char of text) {
325    total += charColumns(char.codePointAt(0) ?? 0)
326  }
327  return total
328}
329
330// Coupe `text` pour qu'il tienne dans `max` colonnes, avec « … » s'il est coupé
331export function fit(text: string, max: number): string {
332  if (columns(text) <= max) {
333    return text
334  }
335  let out = ''
336  let used = 0
337  for (const char of text) {
338    const width = charColumns(char.codePointAt(0) ?? 0)
339    if (used + width > max - 1) {
340      break
341    }
342    out += char
343    used += width
344  }
345  return max > 0 ? `${out.trimEnd()}…` : ''
346}
347
348// Un href que Link accepte : https, ASCII, normalisé
349export function safeHref(url: string): string | undefined {
350  try {
351    const href = new URL(url).href
352    return href.startsWith('https://') && href.length <= 2048 && /^[\x21-\x7e]+$/.test(href) && !href.includes('@')
353      ? href
354      : undefined
355  } catch {
356    return undefined
357  }
358}
359
hooks/transition.ts 16 lines
1// L'effet au changement d'actu : le titre s'écrit de gauche à droite, comme à la
2// machine à écrire, derrière un point discret, sur `frames` images.
3
4const CURSOR = '·'
5
6export function revealFrame(text: string, frame: number, frames: number): string {
7  const chars = Array.from(text)
8  if (frame >= frames) {
9    return text
10  }
11  const shown = Math.floor((chars.length * frame) / frames)
12  const cursor = shown < chars.length ? CURSOR : ''
13  // Même longueur à chaque image : la ligne ne bouge pas pendant l'effet
14  return chars.slice(0, shown).join('') + cursor + ' '.repeat(Math.max(0, chars.length - shown - cursor.length))
15}
16
types/index.d.ts 54 lines
1export type BuiltinId =
2  | 'hc'
3  | 'jdh'
4  | 'camille'
5  | 'linuxfr'
6  | 'korben'
7  | 'anthropic'
8  | 'claudedev'
9  | 'claudecode'
10  | 'openai'
11  | 'deepmind'
12  | 'huggingface'
13  | 'simonw'
14
15// Une source ajoutée avec `/veille ajouter <site>`
16export type CustomId = `perso-${string}`
17
18export type SourceId = BuiltinId | CustomId
19
20export type Filter = SourceId | 'all'
21
22export type Item = {
23  id: string
24  source: SourceId
25  title: string
26  url: string
27  discussUrl?: string
28  at: number
29}
30
31export type CustomSource = {
32  id: CustomId
33  name: string
34  short: string
35  color: string
36  url: string
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'flash-veille': {
42      items: Item[]
43      fetchedAt: number
44      filter: Filter
45      isLoading: boolean
46      errors: string[]
47      index: number
48      frame: number
49      isBandHidden: boolean
50      customSources: CustomSource[]
51    }
52  }
53}
54