Полоса над вводом: погода контекста, лимиты на 5 часов и неделю, кнопка «Сжать»; плюс предохранитель перед опасными командами

Плагин Claude Code: всё, что нужно видеть над строкой ввода, и предохранитель от опасных команд. Работает в десктопе (вкладка Code) и в терминале.
Полоса над вводом:
/compact; если сейчас идёт ответ, сжатие встанет в очередь и выполнится, когда сессия освободится.Предохранитель. Перед опасным вызовом останавливает его, играет короткую сирену и спрашивает: выполнить, разрешать такое до конца сессии или запретить. Над вводом висит красная карточка: что произойдёт, зачем модель это делает и что затронет — для рекурсивного удаления каждый путь с числом файлов и размером. Ловит:
rm -rf, git push --force, reset --hard, clean -f, откат правок, branch -D, curl … | bash, sudo, удаление в kubectl и yc, DROP, TRUNCATE, DELETE без WHERE;В автозадачах отвечать некому: разрушительное запрещается сразу, посты и письма проходят.
claude plugin marketplace add tsergeytovarov/ai-settings
claude plugin install cockpit@popovs
Заработает в новой сессии.
Код полосы и предохранителя живёт в соседних плагинах context-weather и airbag; здесь их копии. Руками копии не правь: правь исходник и синхронизируй.
cd plugins
python3 cockpit/scripts/sync.py # обновить копии
python3 cockpit/scripts/sync.py --check # проверить, что копии свежие
Скрипт переименовывает ключи состояния в cockpit и переносит сирену sounds/alarm.wav. Своё у плагина — только кнопка «Сжать» (hooks/compact.tsx) и hooks/register.tsx, который собирает три части в один модуль: движок разрешает плагину один модуль хуков и одну подписку на событие без фильтра.
Проверка: claude plugin validate . и claude plugin test .
hooks/register.tsx 15 lines1import type { Register } from 'claude-code'
2
3import { register as compact } from './compact'
4import { register as fuse } from './airbag/register'
5import { register as weather } from './weather/register'
6
7// The band above the prompt (weather, limits, «Сжать») and the fuse, as one plugin.
8// The first registered draws outermost: the fuse, so its card spans the whole band
9// on a line of its own above the weather; the weather next, so «Сжать» sits on its line.
10export const register: Register = (on, options) => {
11 fuse(on, options)
12 weather(on, options)
13 compact(on, options)
14}
15hooks/compact.tsx 66 lines1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2
3function reasonOf(error: unknown): string {
4 return error instanceof Error ? error.message : String(error)
5}
6
7// The direct call refuses while the engine counts a turn as running, and a button
8// press may count as one. /compact as a command waits for the session to go idle.
9async function compactNow($: EngineInterface): Promise<void> {
10 try {
11 const result = await $.session.compact({})
12 if (result.messages === undefined) $.ui.toast(`Сжатие отменено: ${result.skip}`)
13 } catch (direct) {
14 $.ui.log(`cockpit: $.session.compact refused: ${reasonOf(direct)}`, { to: 'debug' })
15 try {
16 await $.command.run({ command: 'compact', args: '' })
17 } catch (queued) {
18 $.ui.toast(`Сжать не вышло: ${reasonOf(queued)}`)
19 }
20 }
21}
22
23// chat-memory draws its own «Сжать», which also saves the memory: with it on, one button
24// is enough. A read that fails counts as off, so the band keeps a button.
25async function isChatMemoryOn($: EngineInterface): Promise<boolean> {
26 try {
27 const enabled = (await $.settings.read()).enabledPlugins
28 if (typeof enabled !== 'object' || enabled === null) return false
29 return Object.entries(enabled).some(([id, on]) => id.startsWith('chat-memory@') && on !== false)
30 } catch {
31 return false
32 }
33}
34
35// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew: it may only stand alone.
36function drawnBeneath(rest: RenderElement): RenderElement | null {
37 return rest.type === 'engine' ? null : rest
38}
39
40export const register: Register = on => {
41 // «Сжать» at the right end of the band; what else the band holds takes the room it leaves.
42 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
43 const rest = await next(e)
44 if (e.props.hasSurvey || (await isChatMemoryOn($))) return rest
45
46 const { Box, Button } = $.ui.resolve(e)
47 const button = (
48 <Box flexShrink={0}>
49 <Button key="cockpit-compact" label="🗜 Сжать" onPress={async () => compactNow($)} />
50 </Box>
51 )
52 const others = drawnBeneath(rest)
53 if (others === null) return button
54 // The rest of the band takes the room the button leaves; the button holds to
55 // the top, beside the weather's line, when the fuse's card adds a second line.
56 return (
57 <Box flexDirection="row" alignItems="flex-start" gap={1} width="100%">
58 <Box flexGrow={1} flexShrink={1} minWidth={0}>
59 {others}
60 </Box>
61 {button}
62 </Box>
63 )
64 })
65}
66hooks/airbag/register.tsx 261 lines1// Generated from plugins/airbag by cockpit/scripts/sync.py: edit the source, then sync.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
5import type { AllowedRules, PendingDanger, TargetInfo } from '../../types'
6import { deniedBy, findDanger } from './rules'
7import type { Danger } from './rules'
8import { targetLine } from './targets'
9
10const allowed = atom({ plugin: 'cockpit', key: 'allowed' } as const, [] as AllowedRules)
11const isUnattended = atom({ plugin: 'cockpit', key: 'isUnattended' } as const, false)
12// The question waiting for an answer, for the red card above the prompt.
13const pending = atom({ plugin: 'cockpit', key: 'pending' } as const, null as PendingDanger | null)
14
15export const RUN = 'Выполнить'
16export const RUN_ALWAYS = 'Разрешать до конца сессии'
17export const STOP = 'Запретить'
18export const HEADER = '⛔ Опасно'
19export const ALARM = 'sounds/alarm.wav'
20
21// Folders a walk counts before it stops and says «больше».
22const WALK_LIMIT = 3000
23
24// The home folder, for `~` in a command (the engine does not expand it).
25function homeOf(cwd: string): string | undefined {
26 return /^(\/Users\/[^/]+|\/home\/[^/]+)/.exec(cwd)?.[1]
27}
28
29// What an rm would take with it: per path, whether it is there, and for a
30// folder how many files and bytes lie under it (counted up to a limit).
31function resolve(path: string, home: string | undefined): string {
32 if (home !== undefined && (path === '~' || path.startsWith('~/'))) return home + path.slice(1)
33 return path
34}
35
36async function walk(
37 $: EngineInterface,
38 dir: string,
39 budget: { left: number },
40): Promise<{ files: number; bytes: number; isPartial: boolean }> {
41 let files = 0
42 let bytes = 0
43 let isPartial = false
44 const queue = [dir]
45 while (queue.length > 0) {
46 if (budget.left <= 0) {
47 isPartial = true
48 break
49 }
50 budget.left -= 1
51 const current = queue.shift()!
52 let entries
53 try {
54 entries = await $.fs.list(current)
55 } catch {
56 isPartial = true
57 continue
58 }
59 for (const entry of entries) {
60 if (entry.kind === 'dir' && !entry.isLink) queue.push(`${current}/${entry.name}`)
61 else {
62 files += 1
63 bytes += entry.size
64 }
65 }
66 }
67 return { files, bytes, isPartial }
68}
69
70export async function describeTargets(
71 $: EngineInterface,
72 targets: readonly string[],
73 home: string | undefined,
74 folderLimit: number,
75): Promise<TargetInfo[]> {
76 const budget = { left: folderLimit }
77 const described: TargetInfo[] = []
78 for (const path of targets) {
79 // A glob or a variable is the shell's to expand: say so rather than guess.
80 if (/[*?$`{[]/.test(path)) {
81 described.push({ path, kind: 'pattern' })
82 continue
83 }
84 try {
85 const stat = await $.fs.stat(resolve(path, home))
86 if (stat.kind === 'dir') {
87 const { files, bytes, isPartial } = await walk($, resolve(path, home), budget)
88 described.push({ path, kind: 'dir', files, bytes, isPartial })
89 } else {
90 described.push({ path, kind: 'file', files: 1, bytes: stat.size, isPartial: false })
91 }
92 } catch {
93 described.push({ path, kind: 'missing' })
94 }
95 }
96 return described
97}
98
99const SETTINGS_SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const
100
101// Every `permissions.deny` rule, from each settings source on its own: the merge
102// takes a key from one source and could hide the others' rules. A read that
103// fails gives no rules, so the fuse asks rather than stays silent.
104async function denyRules($: EngineInterface): Promise<string[]> {
105 const rules: string[] = []
106 for (const source of SETTINGS_SOURCES) {
107 try {
108 const permissions = (await $.settings.read({ source })).permissions as { deny?: unknown } | undefined
109 const deny = Array.isArray(permissions?.deny) ? permissions.deny : []
110 rules.push(...deny.filter((rule): rule is string => typeof rule === 'string'))
111 } catch (error) {
112 $.ui.log(`airbag: settings ${source} unread: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
113 }
114 }
115 return rules
116}
117
118// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew the band:
119// it may only stand alone, never inside another tree.
120function drawnBeneath(rest: RenderElement): RenderElement | null {
121 return rest.type === 'engine' ? null : rest
122}
123
124function questionFor(danger: Danger, targets: TargetInfo[]): string {
125 if (targets.length === 0) return `Команда ${danger.what}. Разрешить?`
126 return `Команда ${danger.what}: ${targets.map(t => t.path).join(', ')}. Разрешить?`
127}
128
129async function ask($: EngineInterface, danger: Danger): Promise<string> {
130 const targets =
131 danger.targets === undefined ? [] : await describeTargets($, danger.targets, homeOf(await $.session.cwd()), WALK_LIMIT)
132 const question = questionFor(danger, targets)
133 await update($, pending, () => ({
134 question,
135 what: danger.what,
136 detail: danger.detail,
137 why: danger.why ?? null,
138 targets,
139 }))
140 // Not awaited: the question opens while the alarm plays.
141 $.audio.play({ asset: ALARM }).catch(error => {
142 $.ui.log(`airbag: alarm did not play: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
143 })
144 try {
145 return await $.ui.ask(question, { options: [RUN, RUN_ALWAYS, STOP], header: HEADER })
146 } finally {
147 await update($, pending, () => null)
148 }
149}
150
151// No session.start hook: cockpit bundles this module with context-weather's, and
152// one plugin may hook an event only once without a matcher.
153export const register: Register = on => {
154 // A scheduled task opens with its own marker: nobody is there to answer.
155 on('prompt.submit', async ($, e, next) => {
156 if (e.text.startsWith('<scheduled-task')) await update($, isUnattended, () => true)
157 return next(e)
158 })
159
160 on('tool.call', async ($, e, next) => {
161 // The call's arguments sit on the event itself, beside `tool`.
162 const danger = findDanger(e.tool, e)
163 if (danger === null) return next(e)
164 // A settings deny rule refuses it anyway: no question whose «Выполнить» cannot run.
165 if (deniedBy(e.tool, e, await denyRules($)) !== null) return next(e)
166 if ((await read($, allowed)).includes(danger.rule)) return next(e)
167
168 // A scheduled task posts and mails on purpose; it may not destroy anything.
169 if (await read($, isUnattended)) {
170 if (danger.kind === 'outward') return next(e)
171 return { deny: `Предохранитель: в автозадаче это запрещено — команда ${danger.what}. Сделай без этого или остановись и опиши, что нужно.` }
172 }
173
174 let answer: string
175 try {
176 answer = await ask($, danger)
177 } catch {
178 return { deny: `Предохранитель: подтвердить было некому — команда ${danger.what}. Не повторяй без явной просьбы.` }
179 }
180
181 if (answer === RUN) return next(e)
182 if (answer === RUN_ALWAYS) {
183 await update($, allowed, list => [...list, danger.rule])
184 return next(e)
185 }
186 const why = answer === STOP ? '' : ` Пояснение: ${answer}`
187 return { deny: `Пользователь запретил: команда ${danger.what}. Не повторяй без явной просьбы.${why}` }
188 })
189
190 // The fuse's own question gets a line under each choice and, on «Выполнить»,
191 // a preview of the command and everything it touches.
192 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
193 const waiting = await read($, pending)
194 const [first, ...rest] = e.props.questions as Array<Record<string, unknown>>
195 if (waiting === null || first === undefined || first.question !== waiting.question) return next(e)
196
197 const touched = waiting.targets.map(target => `- ${targetLine(target)}`).join('\n')
198 const preview =
199 '```sh\n' + waiting.detail + '\n```' + (touched === '' ? '' : `\n\n**Затронет:**\n${touched}`)
200 const descriptions: Record<string, string> = {
201 [RUN]: waiting.why === null ? 'Модель не объяснила зачем' : `Зачем: ${waiting.why}`,
202 [RUN_ALWAYS]: 'Больше не спрашивать про такое в этой сессии',
203 [STOP]: 'Модель получит отказ и не будет повторять без просьбы',
204 }
205 const options = (first.options as Array<Record<string, unknown>>).map(option => ({
206 ...option,
207 description: descriptions[String(option.label)] ?? '',
208 ...(option.label === RUN ? { preview } : {}),
209 }))
210 return next({ ...e, props: { ...e.props, questions: [{ ...first, options }, ...rest] } })
211 })
212
213 // While the fuse waits for an answer, a red card above the prompt says what is at stake.
214 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
215 const rest = await next(e)
216 const waiting = await read($, pending)
217 if (waiting === null || e.props.hasSurvey) return rest
218
219 const { Box, Text } = $.ui.resolve(e)
220 const others = drawnBeneath(rest)
221 const card = (
222 // Full width: in a band that wraps (context-weather's) the card takes a line of its own.
223 <Box
224 key="fuse-card"
225 flexDirection="column"
226 borderStyle="round"
227 borderColor="error"
228 paddingX={1}
229 marginBottom={1}
230 width="100%"
231 minWidth={0}
232 >
233 <Text bold color="error">
234 ⛔ Опасно: команда {waiting.what}
235 </Text>
236 <Text wrap="wrap">
237 <Text dimColor>Зачем: </Text>
238 {waiting.why ?? <Text dimColor>модель не объяснила</Text>}
239 </Text>
240 {waiting.targets.map(target => (
241 <Text wrap="truncate-end">
242 <Text color="error">• </Text>
243 {targetLine(target)}
244 </Text>
245 ))}
246 <Text dimColor wrap="truncate-end">
247 {waiting.detail}
248 </Text>
249 <Text dimColor>Ответь в вопросе ниже: выполнить, разрешать до конца сессии или запретить.</Text>
250 </Box>
251 )
252 if (others === null) return card
253 return (
254 <Box flexDirection="column" width="100%">
255 {card}
256 {others}
257 </Box>
258 )
259 })
260}
261hooks/weather/register.tsx 117 lines1// Generated from plugins/context-weather by cockpit/scripts/sync.py: edit the source, then sync.
2import { atom, read, update } from 'claude-code'
3import type { Register, RenderElement } from 'claude-code'
4
5import type { ContextReading } from '../../types'
6import { limitBuddySvg, moodFor } from './claude'
7import { iconSvg } from './icons'
8import { limitViews } from './limits'
9import { bar, forecast } from './weather'
10
11const reading = atom({ plugin: 'cockpit', key: 'reading' } as const, null)
12const limits = atom({ plugin: 'cockpit', key: 'limits' } as const, [])
13
14// CSS pixels; about one line of the prompt's text.
15const ICON_SIZE = 20
16const BAR_CELLS = 10
17
18// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew: the engine's
19// own band, which may only stand alone, never inside another tree.
20function drawnBeneath(rest: RenderElement): RenderElement | null {
21 return rest.type === 'engine' ? null : rest
22}
23
24export const register: Register = on => {
25 on('session.start', async ($, e, next) => {
26 const usage = await $.session.usage()
27 const snapshot: ContextReading = { ...usage.context }
28 await update($, reading, () => snapshot)
29 await update($, limits, () => [...usage.rateLimits])
30
31 return next(e)
32 })
33
34 on('session.measure', async ($, e, next) => {
35 if (e.changed.includes('context')) {
36 const snapshot: ContextReading = { ...e.context }
37 await update($, reading, () => snapshot)
38 }
39 if (e.changed.includes('rateLimits')) {
40 await update($, limits, () => [...e.rateLimits])
41 }
42
43 return next(e)
44 })
45
46 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
47 // What other plugins draw in the band (chat-memory's buttons) goes at the right end.
48 const rest = await next(e)
49 const current = await read($, reading)
50
51 if (e.props.hasSurvey || current === null) {
52 return rest
53 }
54
55 const { Box, Text } = $.ui.resolve(e)
56 const weather = forecast(current.percent)
57 const percent = current.percent === undefined ? '—' : `${current.percent}%`
58
59 // The terminal draws no Svg: there the emoji stands in for the icon.
60 let icon = <Text>{weather.emoji}</Text>
61 if (e.surface !== 'terminal') {
62 const { Svg } = $.ui.resolve(e)
63 icon = (
64 <Svg
65 source={iconSvg(weather.sky)}
66 alt={weather.alt}
67 width={ICON_SIZE}
68 height={ICON_SIZE}
69 isInteractive
70 />
71 )
72 }
73
74 // One tiny Claude per rate-limit window: its ring fills, it yellows, reddens and saddens.
75 // Small enough for any band width; the exact figures are in its tooltip.
76 const windows = limitViews(await read($, limits), await $.clock.now())
77 const buddies = windows.map(w => {
78 const tooltip = `${w.title}: ${w.percent}%${w.resetsIn === undefined ? '' : `, сброс через ${w.resetsIn}`}`
79 let face = (
80 <Text color={w.color}>
81 {moodFor(w.percent).emoji}
82 {w.percent}%
83 </Text>
84 )
85 if (e.surface !== 'terminal') {
86 const { Svg } = $.ui.resolve(e)
87 face = (
88 <Svg source={limitBuddySvg(w.percent, tooltip)} alt={tooltip} width={ICON_SIZE} height={ICON_SIZE} isInteractive />
89 )
90 }
91 return (
92 <Box key={w.label} flexDirection="row" alignItems="center" gap={1}>
93 {face}
94 <Text dimColor>{w.label}</Text>
95 </Box>
96 )
97 })
98
99 // Weather and limits never shrink. What the plugins beneath drew follows them:
100 // small buttons stay at the right end, a full-width card (the fuse's) wraps onto
101 // a line of its own instead of squeezing the weather to nothing.
102 return (
103 <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={1} width="100%">
104 <Box flexDirection="row" alignItems="center" gap={1} flexGrow={1} flexShrink={0}>
105 {icon}
106 {current.percent === undefined ? null : <Text color={weather.color}>{bar(current.percent, BAR_CELLS)}</Text>}
107 <Text color={weather.color}>{percent}</Text>
108 </Box>
109 <Box flexDirection="row" alignItems="center" gap={2} flexShrink={0}>
110 {buddies}
111 </Box>
112 {drawnBeneath(rest)}
113 </Box>
114 )
115 })
116}
117hooks/airbag/rules.ts 274 lines1// Generated from plugins/airbag by cockpit/scripts/sync.py: edit the source, then sync.
2// What the fuse stops, and how it explains it. A rule looks at one tool call
3// and answers what would happen, or null when the call is not its business.
4
5export type Kind = 'destructive' | 'outward'
6
7export type Danger = {
8 rule: string
9 kind: Kind
10 // What happens if the call runs, as a verb phrase: «удалит без корзины».
11 what: string
12 // The command or payload, shortened, to show under the question.
13 detail: string
14 // Why the model makes the call, in its own words (a Bash call's description).
15 why?: string
16 // Paths a recursive rm would delete, as the command writes them.
17 targets?: string[]
18}
19
20type Found = Omit<Danger, 'rule' | 'kind' | 'why'>
21
22type Rule = {
23 id: string
24 kind: Kind
25 check: (tool: string, input: Record<string, unknown>) => Found | null
26 // Words a description must touch to explain this danger. A Bash description
27 // often sums up the whole compound line («Add README, git status») and says
28 // nothing of the rm in it: then the fuse says the reason is missing.
29 about?: RegExp
30}
31
32const DETAIL_LIMIT = 300
33
34function cut(text: string, limit = DETAIL_LIMIT): string {
35 const flat = text.replace(/\s+/g, ' ').trim()
36 return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`
37}
38
39function command(tool: string, input: Record<string, unknown>): string | null {
40 if (tool !== 'Bash') return null
41 return typeof input.command === 'string' ? input.command : null
42}
43
44// Paths a recursive rm may touch freely: throwaway folders.
45const SCRATCH = /^(\/private)?\/tmp\/|\/scratchpad\//
46
47// Quoted text: '…' or "…" with escapes.
48const QUOTED = /(["'])(?:\\.|(?!\1)[^\\])*\1/g
49
50// Leading env assignments and wrappers that run the next word; `rtk` and
51// `rtk proxy` run the command after them too.
52const WRAPPERS = /^(?:\w+=\S*\s+)*(?:(?:time|nohup|exec|command|env|rtk(?:\s+proxy)?|xargs(?:\s+-\S+)*)\s+)*/
53
54// Git's own options before the subcommand: `git -C "$dir" -c k=v --no-pager branch -D x`
55// is `git branch -D x` to the rules. Each group: -C with its path (quoted or not),
56// -c with its setting, a long option with an optional =value, or -P.
57const GIT_GLOBALS = /^git((?:\s+(?:-C\s+(?:"[^"]*"|'[^']*'|\S+)|-c\s+\S+|--[\w-]+(?:=\S+)?|-P))+)(?=\s)/
58
59// The commands a shell line runs, each from its first word. Heredoc bodies are
60// dropped and quoted text loses its separators, so a dangerous word inside a
61// message, a commit text or a script fed through `<<EOF` is not a command.
62export function commandsOf(line: string): string[] {
63 return rawCommandsOf(line)
64 .map(part => part.replace(WRAPPERS, '').replace(GIT_GLOBALS, 'git'))
65 .filter(part => part !== '')
66}
67
68// The same commands as written, wrappers and git options left in place: what
69// the engine's own permission rules are matched against.
70function rawCommandsOf(line: string): string[] {
71 const withoutHeredocs = line.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '')
72 const flattened = withoutHeredocs.replace(QUOTED, quoted => quoted.replace(/[;&|\n]/g, ' '))
73 return flattened
74 .split(/&&|\|\||[;|\n]/)
75 .map(part => part.trim())
76 .filter(part => part !== '')
77}
78
79// A Bash rule's content as a test on one command: `npm test:*` and `rm -rf*`
80// are prefixes, `*` anywhere is any text, anything else is the whole command.
81function bashRuleMatches(content: string, part: string): boolean {
82 if (content.endsWith(':*')) return part.startsWith(content.slice(0, -2))
83 const pattern = content.split('*').map(piece => piece.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')
84 return new RegExp(`^${pattern}$`, 's').test(part)
85}
86
87// The settings' `permissions.deny` rule that blocks this call anyway, or null.
88// Such a call is the engine's to refuse: asking about it would offer «Выполнить»
89// that cannot run. Bash rules are matched against each command as written, so a
90// command the engine would not match (`rtk proxy rm -rf`) still gets the question.
91export function deniedBy(tool: string, input: unknown, deny: readonly string[]): string | null {
92 const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
93 const cmd = command(tool, fields)
94 for (const rule of deny) {
95 const bashRule = /^Bash\((.*)\)$/s.exec(rule)
96 if (bashRule !== null) {
97 if (cmd !== null && rawCommandsOf(cmd).some(part => bashRuleMatches(bashRule[1] ?? '', part))) return rule
98 continue
99 }
100 if (rule === tool) return rule
101 // An MCP server or a `mcp__server__*` family.
102 const family = rule.endsWith('__*') ? rule.slice(0, -1) : `${rule}__`
103 if (rule.startsWith('mcp__') && tool.startsWith(family)) return rule
104 }
105 return null
106}
107
108// A Bash rule: `pattern` is tried on each command from its first word.
109function bash(
110 id: string,
111 pattern: RegExp,
112 about: RegExp,
113 what: (match: RegExpMatchArray) => Omit<Found, 'detail'> | null,
114): Rule {
115 return {
116 id,
117 kind: 'destructive',
118 about,
119 check: (tool, input) => {
120 const cmd = command(tool, input)
121 if (cmd === null) return null
122 for (const part of commandsOf(cmd)) {
123 const match = part.match(pattern)
124 if (match === null) continue
125 const found = what(match)
126 if (found !== null) return { ...found, detail: cut(cmd) }
127 }
128 return null
129 },
130 }
131}
132
133// SQL that removes data: DROP, TRUNCATE, or DELETE with no WHERE.
134const DESTRUCTIVE_SQL = /\b(DROP\s+(TABLE|DATABASE|SCHEMA|VIEW)|TRUNCATE(\s+TABLE)?\s+\w|DELETE\s+FROM\s+[\w.`"]+\s*(;|$|\)|'|"))/i
135const SQL_CLIENT = /^(?:psql|mysql|sqlite3|clickhouse(?:-client)?|duckdb)\b/
136
137function sqlIn(input: Record<string, unknown>): string | null {
138 for (const key of ['query', 'sql', 'statement']) {
139 const value = input[key]
140 if (typeof value === 'string' && DESTRUCTIVE_SQL.test(value)) return value
141 }
142 return null
143}
144
145function mcpServer(tool: string): string {
146 const match = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(tool)
147 return match?.[1] ?? tool
148}
149
150const REMOVING = /удал|снес|снос|стер|стир|чист|чищ|убра|убер|clean|delet|remov|wipe|purge|\brm\b/i
151const FORCE_PUSH = /push|пуш|forc|перезапис|истори|rebase|amend/i
152const DISCARDING = /reset|сброс|откат|отмен|discard|restor|revert|верн|выбро/i
153const BRANCHES = /ветк|branch|удал|delet|remov|чист|чищ|clean/i
154const INSTALLING = /install|установ|скрипт|script|setup/i
155const SUPERUSER = /sudo|прав|админ|root|install|установ|систем/i
156const CLOUD = /удал|снес|delet|remov|uninstall|destroy|откат|rollback/i
157const DATA = /удал|delet|drop|truncat|чист|чищ|clean|очист|сброс|reset|миграц|migrat/i
158
159export const RULES: readonly Rule[] = [
160 bash('rm-recursive', /^(?:sudo\s+)?rm\s+((?:-\S+(?:\s+|$))*)(.*)$/, REMOVING, match => {
161 if (!/(^|\s)(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$)/.test(match[1] ?? '')) return null
162 const targets = (match[2] ?? '')
163 .trim()
164 .split(/\s+/)
165 .filter(t => t !== '' && !t.startsWith('-'))
166 .map(t => t.replace(/^["']|["']$/g, ''))
167 if (targets.length > 0 && targets.every(t => SCRATCH.test(t))) return null
168 return { what: 'удалит без корзины', targets }
169 }),
170 bash('git-force-push', /^git\s+push\b.*\s(?:--force(?:-with-lease)?|-f)\b/, FORCE_PUSH, () => ({
171 what: 'перезапишет историю ветки на сервере — чужие коммиты могут пропасть',
172 })),
173 bash('git-reset-hard', /^git\s+reset\b.*--hard\b/, DISCARDING, () => ({ what: 'сотрёт все незакоммиченные изменения' })),
174 bash('git-clean', /^git\s+clean\b.*\s-[a-zA-Z]*f/, REMOVING, () => ({ what: 'удалит все неотслеживаемые файлы — их не вернуть' })),
175 bash('git-discard', /^git\s+(?:checkout\s+--\s|restore\s+(?!--staged)[^-])/, DISCARDING, () => ({ what: 'откатит правки, которых нет в коммитах' })),
176 bash('git-branch-delete', /^git\s+branch\b.*\s-D\b/, BRANCHES, () => ({ what: 'удалит ветку, даже если она не влита' })),
177 {
178 id: 'pipe-to-shell',
179 kind: 'destructive',
180 about: INSTALLING,
181 check: (tool, input) => {
182 const cmd = command(tool, input)
183 if (cmd === null) return null
184 const flat = cmd.replace(QUOTED, '""')
185 return /\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/.test(flat)
186 ? { what: 'скачает скрипт из интернета и сразу выполнит его, не показав', detail: cut(cmd) }
187 : null
188 },
189 },
190 bash('sudo', /^sudo\s/, SUPERUSER, () => ({ what: 'выполнит команду с правами администратора' })),
191 bash('cloud-delete', /^(?:kubectl|helm|yc)\b.*\b(?:delete|uninstall|destroy)\b/, CLOUD, () => ({ what: 'удалит ресурс в облаке или кластере' })),
192 {
193 id: 'sql-destructive',
194 kind: 'destructive',
195 about: DATA,
196 check: (tool, input) => {
197 const cmd = command(tool, input)
198 if (cmd === null || !DESTRUCTIVE_SQL.test(cmd)) return null
199 return commandsOf(cmd).some(part => SQL_CLIENT.test(part)) ? { what: 'удалит данные в базе', detail: cut(cmd) } : null
200 },
201 },
202 {
203 id: 'mcp-sql-destructive',
204 kind: 'destructive',
205 check: (tool, input) => {
206 if (!tool.startsWith('mcp__')) return null
207 const sql = sqlIn(input)
208 return sql === null ? null : { what: `удалит данные в базе ${mcpServer(tool)}`, detail: cut(sql) }
209 },
210 },
211 {
212 id: 'mcp-delete',
213 kind: 'destructive',
214 check: tool => {
215 if (!tool.startsWith('mcp__')) return null
216 const action = tool.slice(tool.lastIndexOf('__') + 2)
217 if (/(^|_)(delete|trash|remove_permission|destroy)(_|$)/.test(action)) {
218 return { what: `удалит в ${mcpServer(tool)} (${action})`, detail: action }
219 }
220 if (/(^|_)bulk(_|$)/.test(action)) {
221 return { what: `массово изменит в ${mcpServer(tool)} (${action})`, detail: action }
222 }
223 return null
224 },
225 },
226 {
227 id: 'artifact-delete',
228 kind: 'destructive',
229 check: (tool, input) =>
230 tool === 'Artifact' && input.action === 'delete'
231 ? { what: 'удалит артефакт — ссылка перестанет работать у всех', detail: String(input.url ?? '') }
232 : null,
233 },
234 {
235 id: 'mattermost-post',
236 kind: 'outward',
237 check: (tool, input) => {
238 if (!/mattermost_post_create$/.test(tool)) return null
239 const text = typeof input.message === 'string' ? input.message : JSON.stringify(input)
240 return { what: 'отправит сообщение в Mattermost', detail: cut(text) }
241 },
242 },
243 {
244 id: 'mail-send',
245 kind: 'outward',
246 check: (tool, input) => {
247 if (!tool.startsWith('mcp__') || !/__(send_message|forward|reply)$/.test(tool)) return null
248 const to = input.to ?? input.recipients ?? ''
249 return {
250 what: `отправит письмо${to === '' ? '' : ` (${cut(String(to), 60)})`}`,
251 detail: cut(String(input.subject ?? input.body ?? '')),
252 }
253 },
254 },
255 {
256 id: 'drive-share',
257 kind: 'outward',
258 check: tool => (/drive_share$/.test(tool) ? { what: 'откроет доступ к файлу на Google Drive', detail: tool } : null),
259 },
260]
261
262export function findDanger(tool: string, input: unknown): Danger | null {
263 const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
264 const described = typeof fields.description === 'string' ? fields.description.trim() : ''
265 for (const rule of RULES) {
266 const found = rule.check(tool, fields)
267 if (found === null) continue
268 // A description that never touches this danger does not explain it.
269 const explains = described !== '' && (rule.about === undefined || rule.about.test(described))
270 return { rule: rule.id, kind: rule.kind, ...found, ...(explains ? { why: described } : {}) }
271 }
272 return null
273}
274hooks/airbag/targets.ts 42 lines1// Generated from plugins/airbag by cockpit/scripts/sync.py: edit the source, then sync.
2import type { TargetInfo } from '../../types'
3
4// How the fuse words what an rm would take with it. The walk that counts it
5// lives in register.tsx: `$` may not cross an import.
6
7function formatBytes(bytes: number): string {
8 const units = ['Б', 'КБ', 'МБ', 'ГБ']
9 let value = bytes
10 let unit = 0
11 while (value >= 1024 && unit < units.length - 1) {
12 value /= 1024
13 unit += 1
14 }
15 return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
16}
17
18function plural(n: number, one: string, few: string, many: string): string {
19 const last = n % 10
20 const lastTwo = n % 100
21 if (last === 1 && lastTwo !== 11) return one
22 if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return few
23 return many
24}
25
26// One line per path for the card and the dialog's preview.
27export function targetLine(target: TargetInfo): string {
28 switch (target.kind) {
29 case 'pattern':
30 return `${target.path} — шаблон, что под него попадёт, решит shell`
31 case 'missing':
32 return `${target.path} — не найден или нет доступа посмотреть`
33 case 'file':
34 return `${target.path} — файл, ${formatBytes(target.bytes ?? 0)}`
35 case 'dir': {
36 const files = target.files ?? 0
37 const more = target.isPartial ? ' (посчитано не всё — внутри больше)' : ''
38 return `${target.path} — папка: ${files.toLocaleString('ru')} ${plural(files, 'файл', 'файла', 'файлов')}, ${formatBytes(target.bytes ?? 0)}${more}`
39 }
40 }
41}
42hooks/weather/claude.ts 141 lines1// Generated from plugins/context-weather by cockpit/scripts/sync.py: edit the source, then sync.
2import { FRAME_STYLE } from './icons'
3
4// A tiny round Claude per rate-limit window, inside a ring that fills with the
5// share used: the less limit is left, the yellower, redder and sadder it gets.
6// 32x32, animated with CSS keyframes like the weather.
7
8export type Mood = 'joy' | 'calm' | 'sad' | 'crying' | 'drained'
9
10export type MoodView = { mood: Mood; emoji: string; alt: string }
11
12const FACE = '#3A2620'
13const CHEEK = '#F2A48A'
14const TEAR = '#7CC4FF'
15
16// Upper bound of percent used (exclusive) -> mood. Last entry catches the rest.
17const MOODS: ReadonlyArray<readonly [number, MoodView]> = [
18 [25, { mood: 'joy', emoji: '😄', alt: 'Claude радуется: лимита много' }],
19 [50, { mood: 'calm', emoji: '🙂', alt: 'Claude доволен' }],
20 [75, { mood: 'sad', emoji: '😢', alt: 'Claude грустит: половина лимита позади' }],
21 [100, { mood: 'crying', emoji: '😭', alt: 'Claude плачет: лимит на исходе' }],
22 [Infinity, { mood: 'drained', emoji: '😵', alt: 'Claude без сил: лимит исчерпан' }],
23]
24
25export function moodFor(percentUsed: number): MoodView {
26 const match = MOODS.find(([upper]) => percentUsed < upper)
27 return (match ?? MOODS[MOODS.length - 1]!)[1]
28}
29
30const STYLE = `
31.hop{animation:hop .8s ease-in-out infinite}
32.sway{animation:sway 1.6s ease-in-out infinite}
33.blink{transform-box:fill-box;transform-origin:center;animation:blink 3.2s infinite}
34.tear{animation:tear 1.2s ease-in infinite}
35@keyframes hop{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}
36@keyframes sway{0%,100%{transform:translateY(0)}50%{transform:translateY(-0.5px)}}
37@keyframes blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.15)}}
38@keyframes tear{0%{transform:translateY(0);opacity:1}80%{opacity:1}100%{transform:translateY(7px);opacity:0}}
39@media (prefers-reduced-motion:reduce){*{animation:none!important}}
40`
41
42const n = (value: number) => Number(value.toFixed(2))
43
44export type Level = 'ok' | 'warn' | 'alarm'
45
46// One color for body and ring, by how much of the window is used.
47const LEVELS: Record<Level, string> = {
48 ok: '#3FB950',
49 warn: '#E8B931',
50 alarm: '#E5484D',
51}
52
53// Green below half, yellow to 90%, red from there.
54export function levelFor(percentUsed: number): Level {
55 if (percentUsed >= 90) return 'alarm'
56 if (percentUsed >= 50) return 'warn'
57 return 'ok'
58}
59
60// Room above and below the ring for the whole character to hop.
61const RING_R = 13.5
62const RING_LENGTH = 2 * Math.PI * RING_R
63
64// The share used as an arc from the top, clockwise, over a faint track.
65function ring(percentUsed: number, color: string): string {
66 const filled = n((Math.max(0, Math.min(100, percentUsed)) / 100) * RING_LENGTH)
67 return (
68 `<circle cx="16" cy="16" r="${RING_R}" fill="none" stroke="#8884" stroke-width="3"/>` +
69 `<circle cx="16" cy="16" r="${RING_R}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" ` +
70 `stroke-dasharray="${filled} ${n(RING_LENGTH)}" transform="rotate(-90 16 16)"/>`
71 )
72}
73
74function eyes(droop: number): string {
75 const y = n(15.5 + droop)
76 return (
77 `<g class="blink"><ellipse cx="12" cy="${y}" rx="1.6" ry="2" fill="${FACE}"/>` +
78 `<ellipse cx="20" cy="${y}" rx="1.6" ry="2" fill="${FACE}"/></g>`
79 )
80}
81
82// Inner ends up: sad and crying brows.
83function brows(lift: number): string {
84 return (
85 `<g stroke="${FACE}" stroke-width="1.4" stroke-linecap="round">` +
86 `<path d="M9.5 ${n(12 + lift)}L13.5 10.5"/><path d="M22.5 ${n(12 + lift)}L18.5 10.5"/></g>`
87 )
88}
89
90function mouth(d: string): string {
91 return `<path d="${d}" fill="none" stroke="${FACE}" stroke-width="1.6" stroke-linecap="round"/>`
92}
93
94function tear(x: number, delay: number): string {
95 return (
96 `<g class="tear" style="animation-delay:-${delay}s">` +
97 `<path d="M${x} 18q-1.3 2 0 2.6q1.3-.6 0-2.6z" fill="${TEAR}"/></g>`
98 )
99}
100
101const FACES: Record<Mood, string> = {
102 joy:
103 eyes(0) +
104 `<circle cx="9.5" cy="19.5" r="1.6" fill="${CHEEK}"/><circle cx="22.5" cy="19.5" r="1.6" fill="${CHEEK}"/>` +
105 `<path d="M12 20q4 4.5 8 0z" fill="${FACE}"/>`,
106 calm: eyes(0) + mouth('M12.5 20.5q3.5 2.5 7 0'),
107 sad: brows(1.5) + eyes(1) + mouth('M12.5 23q3.5-2.8 7 0'),
108 crying: brows(1.5) + eyes(1) + mouth('M12 23.5q4-3.2 8 0') + tear(11, 0) + tear(21, 0.6),
109 drained:
110 `<g stroke="${FACE}" stroke-width="1.5" stroke-linecap="round">` +
111 '<path d="M10.5 14l3 3M13.5 14l-3 3M18.5 14l3 3M21.5 14l-3 3"/></g>' +
112 mouth('M12.5 22.5h7'),
113}
114
115// Happy moods hop, sad ones slump a pixel lower and keep still.
116const POSE: Record<Mood, { dy: number; motion: string }> = {
117 joy: { dy: 0, motion: 'hop' },
118 calm: { dy: 0, motion: 'sway' },
119 sad: { dy: 1, motion: '' },
120 crying: { dy: 1, motion: '' },
121 drained: { dy: 1, motion: '' },
122}
123
124// `title` is the tooltip the desktop shows over the interactive frame.
125export function limitBuddySvg(percentUsed: number, title: string): string {
126 const { mood } = moodFor(percentUsed)
127 const color = LEVELS[levelFor(percentUsed)]
128 const { dy, motion } = POSE[mood]
129 const safeTitle = title.replace(/&/g, '&').replace(/</g, '<')
130 // The face was drawn for a body of r=11 at (16,17); here the body is r=10 at (16,16).
131 const face = `<g transform="translate(16 16) scale(0.82) translate(-16 -17)">${FACES[mood]}</g>`
132 return (
133 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">' +
134 `<title>${safeTitle}</title><style>${FRAME_STYLE}${STYLE}</style>` +
135 // The ring moves with the body: the whole character hops or slumps.
136 `<g transform="translate(0 ${dy * 0.5})"><g class="${motion}">` +
137 ring(percentUsed, color) +
138 `<circle cx="16" cy="16" r="10" fill="${color}"/>${face}</g></g></svg>`
139 )
140}
141hooks/weather/icons.ts 114 lines1// Generated from plugins/context-weather by cockpit/scripts/sync.py: edit the source, then sync.
2import type { Sky } from './weather'
3
4// Flat 2D weather icons, 32x32, animated with CSS keyframes. The band draws
5// them with Svg isInteractive, in a frame where the keyframes run.
6// Every animated <g> sits inside a positioned <g>: a CSS transform replaces
7// the element's transform attribute, so the two never share an element.
8
9const SUN = '#FFC531'
10const RAY = '#FFA41B'
11const CLOUD_LIGHT = '#EEF2F6'
12const CLOUD_EDGE = '#C3CCD6'
13const CLOUD_GREY = '#A3AEBA'
14const CLOUD_DARK = '#6E7987'
15const CLOUD_STORM = '#4E5866'
16const DROP = '#4DA3FF'
17const BOLT = '#FFD43B'
18const SWIRL = '#E5484D'
19
20// Without color-scheme the frame stays light inside the dark app and Chrome
21// paints it opaque white; with both schemes it takes the app's and stays clear.
22export const FRAME_STYLE = ':root{color-scheme:light dark;background:transparent}'
23
24const STYLE = `
25.spin{transform-box:fill-box;transform-origin:center;animation:spin 12s linear infinite}
26.spin-fast{transform-box:fill-box;transform-origin:center;animation:spin 1.4s linear infinite}
27.drift{animation:drift 4s ease-in-out infinite}
28.drift-back{animation:drift 5s ease-in-out infinite reverse}
29.bob{animation:bob 3s ease-in-out infinite}
30.rise{animation:rise 4s ease-in-out infinite}
31.pulse{animation:pulse 2s ease-in-out infinite}
32.drop{animation:fall 1s linear infinite}
33.flash{animation:flash 2.4s steps(1) infinite}
34@keyframes spin{to{transform:rotate(360deg)}}
35@keyframes drift{0%,100%{transform:translateX(-1.2px)}50%{transform:translateX(1.2px)}}
36@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(1px)}}
37@keyframes rise{0%,100%{transform:translateY(1.5px)}50%{transform:translateY(-0.5px)}}
38@keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
39@keyframes fall{0%{transform:translateY(-2px);opacity:0}20%{opacity:1}100%{transform:translateY(6px);opacity:0}}
40@keyframes flash{0%,70%{opacity:1}74%{opacity:.15}78%{opacity:1}82%,92%{opacity:.15}}
41@media (prefers-reduced-motion:reduce){*{animation:none!important}}
42`
43
44function rays(cx: number, cy: number, inner: number, outer: number, width: number): string {
45 const lines = Array.from({ length: 8 }, (_, i) => {
46 const angle = (i * Math.PI) / 4
47 const point = (r: number) =>
48 `${(cx + r * Math.cos(angle)).toFixed(2)} ${(cy + r * Math.sin(angle)).toFixed(2)}`
49 return `<path d="M${point(inner)}L${point(outer)}"/>`
50 })
51 return `<g stroke="${RAY}" stroke-width="${width}" stroke-linecap="round">${lines.join('')}</g>`
52}
53
54function sun(cx: number, cy: number, r: number): string {
55 const spinning = `<g class="spin">${rays(cx, cy, r + 2.6, r + 5.4, r / 2.8)}</g>`
56 return `${spinning}<circle cx="${cx}" cy="${cy}" r="${r}" fill="${SUN}"/>`
57}
58
59// A cloud 24 wide and 14 tall, its top-left corner at (x, y).
60function cloud(x: number, y: number, scale: number, fill: string, motion: string, edge?: string): string {
61 const stroke = edge ? ` stroke="${edge}" stroke-width="1"` : ''
62 const shape =
63 `<g fill="${fill}"${stroke}>` +
64 '<circle cx="6" cy="9" r="5"/><circle cx="12.5" cy="6" r="6"/><circle cx="18.5" cy="9" r="5"/>' +
65 '<rect x="1" y="8.5" width="22.5" height="5.5" rx="2.75"/></g>' +
66 // Same shape without the stroke on top, so the inner seams of the circles disappear.
67 (edge
68 ? `<g fill="${fill}"><circle cx="6" cy="9" r="4.5"/><circle cx="12.5" cy="6" r="5.5"/>` +
69 '<circle cx="18.5" cy="9" r="4.5"/><rect x="1.5" y="9" width="21.5" height="4.5" rx="2.25"/></g>'
70 : '')
71 return `<g transform="translate(${x} ${y}) scale(${scale})"><g class="${motion}">${shape}</g></g>`
72}
73
74function drops(xs: number[], top: number): string {
75 return xs
76 .map(
77 (x, i) =>
78 `<g class="drop" style="animation-delay:-${(i * 0.33).toFixed(2)}s">` +
79 `<path d="M${x} ${top}l-1 3" stroke="${DROP}" stroke-width="2" stroke-linecap="round"/></g>`,
80 )
81 .join('')
82}
83
84const BODIES: Record<Sky, string> = {
85 dawn:
86 `<defs><clipPath id="sky"><rect width="32" height="23"/></clipPath></defs>` +
87 `<g clip-path="url(#sky)"><g class="rise">` +
88 `<g class="pulse">${rays(16, 22, 9, 12, 2.2)}</g>` +
89 `<circle cx="16" cy="22" r="7" fill="${SUN}"/></g></g>` +
90 `<path d="M4 23.5H28" stroke="${RAY}" stroke-width="2" stroke-linecap="round"/>`,
91 clear: sun(16, 16, 6.5),
92 mostlyClear: sun(12, 12, 5) + cloud(9, 15, 0.85, CLOUD_LIGHT, 'drift', CLOUD_EDGE),
93 cloudy:
94 cloud(9, 6, 0.8, CLOUD_GREY, 'drift-back') + cloud(3, 13, 0.95, CLOUD_LIGHT, 'drift', CLOUD_EDGE),
95 overcast: cloud(8, 5, 0.85, CLOUD_DARK, 'drift-back') + cloud(3, 12, 1, CLOUD_GREY, 'bob'),
96 rain: cloud(4, 4, 1, CLOUD_GREY, 'bob') + drops([10, 16, 22], 21),
97 storm:
98 cloud(4, 3, 1, CLOUD_STORM, 'bob') +
99 `<g class="flash"><path d="M17 15l-4 7h3.5l-2 6 6-8.5h-3.6l2.1-4.5z" fill="${BOLT}"/></g>` +
100 drops([9, 24], 20),
101 hurricane:
102 `<g class="spin-fast">` +
103 `<path d="M16 12C16 6.5 20.5 4 26 4.5C22 6 20 9 20 12.5Z" fill="${SWIRL}"/>` +
104 `<path d="M16 20C16 25.5 11.5 28 6 27.5C10 26 12 23 12 19.5Z" fill="${SWIRL}"/>` +
105 `<circle cx="16" cy="16" r="4.2" fill="none" stroke="${SWIRL}" stroke-width="2.6"/></g>`,
106}
107
108export function iconSvg(sky: Sky): string {
109 return (
110 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">' +
111 `<style>${FRAME_STYLE}${STYLE}</style>${BODIES[sky]}</svg>`
112 )
113}
114hooks/weather/limits.ts 47 lines1// Generated from plugins/context-weather by cockpit/scripts/sync.py: edit the source, then sync.
2import type { ThemeKey } from 'claude-code'
3
4import type { RateLimitReading } from '../../types'
5
6export type LimitView = { label: string; title: string; percent: number; resetsIn?: string; color: ThemeKey }
7
8// kind, the short label beside the character, the name in its tooltip.
9const LABELS: ReadonlyArray<readonly [string, string, string]> = [
10 ['five_hour', '5ч', 'Лимит на 5 часов'],
11 ['seven_day', '7д', 'Лимит на неделю'],
12]
13
14const HOUR = 60 * 60 * 1000
15const DAY = 24 * HOUR
16
17export function formatDuration(ms: number): string {
18 if (ms <= 0) return 'сейчас'
19 if (ms >= DAY) return `${Math.floor(ms / DAY)}д ${Math.floor((ms % DAY) / HOUR)}ч`
20 if (ms >= HOUR) return `${Math.floor(ms / HOUR)}ч ${Math.floor((ms % HOUR) / 60_000)}м`
21 return `${Math.max(1, Math.floor(ms / 60_000))}м`
22}
23
24// The same thresholds as the character's color (levelFor in claude.ts).
25function colorFor(percent: number): ThemeKey {
26 if (percent >= 90) return 'error'
27 if (percent >= 50) return 'warning'
28 return 'success'
29}
30
31// The 5-hour and weekly windows in that order; the ones with no reading are left out.
32export function limitViews(limits: readonly RateLimitReading[], now: number): LimitView[] {
33 return LABELS.flatMap(([kind, label, title]) => {
34 const limit = limits.find(l => l.kind === kind)
35 if (!limit) return []
36 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
37 const view: LimitView = {
38 label,
39 title,
40 percent: Math.round(limit.percentUsed),
41 color: colorFor(limit.percentUsed),
42 }
43 if (!Number.isNaN(resetsAt)) view.resetsIn = formatDuration(resetsAt - now)
44 return [view]
45 })
46}
47hooks/weather/weather.ts 45 lines1// Generated from plugins/context-weather by cockpit/scripts/sync.py: edit the source, then sync.
2import type { ThemeKey } from 'claude-code'
3
4export type Sky =
5 | 'dawn'
6 | 'clear'
7 | 'mostlyClear'
8 | 'cloudy'
9 | 'overcast'
10 | 'rain'
11 | 'storm'
12 | 'hurricane'
13
14export type Forecast = {
15 sky: Sky
16 // Fallback for surfaces without Svg (the terminal), and the icon's alt text.
17 emoji: string
18 alt: string
19 color: ThemeKey
20}
21
22// Upper bound of percent used (exclusive) -> forecast. Last entry catches the rest.
23const SCALE: ReadonlyArray<readonly [number, Forecast]> = [
24 [20, { sky: 'clear', emoji: '☀️', alt: 'Ясно', color: 'success' }],
25 [40, { sky: 'mostlyClear', emoji: '🌤', alt: 'Малооблачно', color: 'success' }],
26 [60, { sky: 'cloudy', emoji: '⛅', alt: 'Облачно', color: 'text' }],
27 [75, { sky: 'overcast', emoji: '🌥', alt: 'Пасмурно', color: 'warning' }],
28 [85, { sky: 'rain', emoji: '🌧', alt: 'Дождь', color: 'warning' }],
29 [95, { sky: 'storm', emoji: '⛈', alt: 'Гроза', color: 'error' }],
30 [Infinity, { sky: 'hurricane', emoji: '🌪', alt: 'Ураган', color: 'error' }],
31]
32
33export const DAWN: Forecast = { sky: 'dawn', emoji: '🌅', alt: 'Рассвет', color: 'subtle' }
34
35export function forecast(percent: number | undefined): Forecast {
36 if (percent === undefined) return DAWN
37 const match = SCALE.find(([upper]) => percent < upper)
38 return (match ?? SCALE[SCALE.length - 1]!)[1]
39}
40
41export function bar(percent: number, cells: number): string {
42 const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
43 return '█'.repeat(filled) + '░'.repeat(cells - filled)
44}
45types/index.d.ts 42 lines1// The state of both halves: the band (weather, limits) and the fuse.
2
3export type ContextReading = { tokens?: number; window: number; percent?: number }
4
5// The fields of SessionRateLimit the band reads.
6export type RateLimitReading = { kind: string; percentUsed: number; resetsAt?: string }
7
8// Rule ids the person allowed for the rest of the session.
9export type AllowedRules = string[]
10
11// One path an rm would delete, as found on disk.
12export type TargetInfo = {
13 path: string
14 kind: 'file' | 'dir' | 'missing' | 'pattern'
15 files?: number
16 bytes?: number
17 // The count stopped at its limit, or a folder could not be read.
18 isPartial?: boolean
19}
20
21// The question the fuse is waiting on, for the card above the prompt and the dialog.
22export type PendingDanger = {
23 question: string
24 what: string
25 detail: string
26 why: string | null
27 targets: TargetInfo[]
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 cockpit: {
33 reading: ContextReading | null
34 limits: RateLimitReading[]
35 allowed: AllowedRules
36 // The session runs a scheduled task: nobody is there to answer.
37 isUnattended: boolean
38 pending: PendingDanger | null
39 }
40 }
41}
42