SLOPSHOPPER

cockpit

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

newbandrowsguardtoastprompt
★ 15v0.2.0MITupdated 2026-10-10tsergeytovarov/ai-settings/plugins/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 ⛅ █████░░░░░ 49% 🙂31% 5ч [ 🗜 Сжать ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⛅ █████░░░░░ 49% 🙂31% 5ч [ 🗜 Сжать ]
README

cockpit

Плагин Claude Code: всё, что нужно видеть над строкой ввода, и предохранитель от опасных команд. Работает в десктопе (вкладка Code) и в терминале.

Что внутри

Полоса над вводом:

  • погода контекстного окна: от ясного неба до урагана по мере заполнения, прогресс-бар и процент;
  • лимиты на 5 часов и неделю: два маленьких Claude в кольцах, которые желтеют, краснеют и грустнеют вместе с лимитом; в подсказке процент и время до сброса;
  • кнопка «🗜 Сжать»: обычный /compact; если сейчас идёт ответ, сжатие встанет в очередь и выполнится, когда сессия освободится.

Предохранитель. Перед опасным вызовом останавливает его, играет короткую сирену и спрашивает: выполнить, разрешать такое до конца сессии или запретить. Над вводом висит красная карточка: что произойдёт, зачем модель это делает и что затронет — для рекурсивного удаления каждый путь с числом файлов и размером. Ловит:

  • rm -rf, git push --force, reset --hard, clean -f, откат правок, branch -D, curl … | bash, sudo, удаление в kubectl и yc, DROP, TRUNCATE, DELETE без WHERE;
  • в MCP — удаление, массовые правки, разрушительный SQL, посты в Mattermost, письма, открытие доступа на Drive; удаление артефакта.

В автозадачах отвечать некому: разрушительное запрещается сразу, посты и письма проходят.

Установка

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 .

Source 11 files
hooks/register.tsx 15 lines
1import 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}
15
hooks/compact.tsx 66 lines
1import 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}
66
hooks/airbag/register.tsx 261 lines
1// 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}
261
hooks/weather/register.tsx 117 lines
1// 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}
117
hooks/airbag/rules.ts 274 lines
1// 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}
274
hooks/airbag/targets.ts 42 lines
1// 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}
42
hooks/weather/claude.ts 141 lines
1// 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, '&amp;').replace(/</g, '&lt;')
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}
141
hooks/weather/icons.ts 114 lines
1// 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}
114
hooks/weather/limits.ts 47 lines
1// 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}
47
hooks/weather/weather.ts 45 lines
1// 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}
45
types/index.d.ts 42 lines
1// 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