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…

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 :

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.
/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.
| Commande | Effet |
|---|---|
/veille | Affiche le bandeau et rafraîchit les actus |
/veille tout | Ouvre la liste complète dans un panneau, filtrable par source |
/veille off | Masque le bandeau (et le panneau) ; mémorisé d'une session à l'autre |
/veille sources | Liste 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.
Dans /config, sous flash-veille :
| Option | Par défaut | Effet |
|---|---|---|
| Sources | hc, jdh, camille, linuxfr, anthropic, claudedev, claudecode | Les sources intégrées à suivre |
| Rotation (secondes) | 12 | Duré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és | non | N'affiche que ces actus |
| Code | Source | Par défaut |
|---|---|---|
hc | Human Coders News | ✓ |
jdh | Journal du hacker | ✓ |
camille | La veille de Camille Roux, publiée sur Bluesky | ✓ |
linuxfr | Dépêches LinuxFr.org | ✓ |
anthropic | Anthropic News ¹ | ✓ |
claudedev | Blog claude.dev | ✓ |
claudecode | Releases de Claude Code | ✓ |
korben | Korben | |
openai | OpenAI News | |
deepmind | Google DeepMind | |
huggingface | Hugging Face | |
simonw | Simon Willison |
¹ anthropic.com n'a pas de flux RSS officiel : Flash veille lit le miroir communautaire Olshansk/rss-feeds.
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.
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 :
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 ;/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 hookssession.start(registers/veille, starts the timers),command.run(only/veille) andui.render(its own band above the prompt and its own pane).
claude plugin validate .
claude plugin test .
Pour essayer vos modifications : claude --plugin-dir ..
MIT, par Camille Roux.
hooks/register.tsx 377 lines1import { 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}
377hooks/discover.ts 120 lines1// `/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}
120hooks/feeds.ts 359 lines1import 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}
359hooks/transition.ts 16 lines1// 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}
16types/index.d.ts 54 lines1export 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