SLOPSHOPPER

docker-manager

Панель Docker по образцу плагина VS Code: compose-проекты и контейнеры со стартом, остановкой, логами и удалением, образы, сети и тома

newpanecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · docker-manager
│ ┃ Docker ✕ › fix the failing auth test and add an audit log call │ ┃ [ Контейнеры 0 ] [ Образы 0 ] [ Сети 0 ] [ Т │ ┃ ⏺ 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 │ │ › /docker │ ⎿ docker-manager: Панель Docker открыта. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Docker
[ Контейнеры 0 ] [ Образы 0 ] [ Сети 0 ] [ Тома 0 ] [ Обнови ╭──────────────────────────────────────────────────────────╮ │ │ │ Нет контейнеров │ │ │ ╰──────────────────────────────────────────────────────────╯
README

docker-manager

версия Claude Code

Панель Docker внутри Claude Code, как у плагина Docker в VS Code. Контейнерами управляете вы сами, кнопками, без запросов к Claude.

Как открыть

Команда /docker в чате. Панель встаёт сбоку и, пока открыта, обновляется каждые 5 секунд.

Что умеет

ВкладкаЧто видноЧто можно сделать
КонтейнерыCompose-проекты деревом и «Отдельные контейнеры», у группы счётчик «запущено / всего»; у контейнера статус, образ и опубликованные порты ссылками на localhostСтарт, Стоп, Рестарт, Логи (последние 300 строк, stdout и stderr по времени), Копировать ID, Удалить; у проекта Старт, Стоп, Рестарт и compose down
ОбразыРепозиторий и тег, размер, возраст, сколько контейнеров используютУдалить образ, удалить висячие (docker image prune)
СетиИмя, драйвер, область, compose-проектУдалить (кроме bridge, host, none), удалить неиспользуемые
ТомаИмя, драйвер, compose-проектУдалить, удалить анонимные (docker volume prune)

Удаление контейнера, образа, сети или тома, compose down и очистка спрашивают второй раз: «Да / Отмена». Результат действия показывается строкой над списком и пропадает сам: успех через 5 секунд, ошибка через 15.

Если Docker Desktop не запущен, панель так и скажет.

Что нужно

  • Docker CLI в PATH (Docker Desktop или Docker Engine).
  • Для compose down — Docker Compose v2. Compose-файл не нужен: проект находится по имени.

Установка

claude plugin marketplace add petrovyuri/claude-code-mods
claude plugin install docker-manager@petrovyuri-mods --scope user

В открытой сессии выполните /reload-plugins, затем /docker.

Изменения по версиям: CHANGELOG.md.

Source 4 files
hooks/register.tsx 355 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Container, Image, ListState, Logs, Network, Notice, PaneTab, Volume } from '../types'
5import {
6  CONTAINERS_ARGV,
7  IMAGES_ARGV,
8  IMAGE_PRUNE_ARGV,
9  NETWORKS_ARGV,
10  NETWORK_PRUNE_ARGV,
11  VOLUMES_ARGV,
12  VOLUME_PRUNE_ARGV,
13  composeDownArgv,
14  containerArgv,
15  describeError,
16  imageRef,
17  logsArgv,
18  mergeLogs,
19  parseContainers,
20  parseImages,
21  parseNetworks,
22  parseVolumes,
23  reclaimed,
24  removeContainersArgv,
25  removeImageArgv,
26  removeNetworkArgv,
27  removeVolumeArgv,
28  shortName,
29  type ContainerAction,
30} from './docker'
31import { drawPane } from './pane'
32
33const PANE = 'docker'
34const POLL_MS = 5000
35// Stopping waits up to ten seconds a container before it kills it.
36const ACTION_TIMEOUT_MS = 180_000
37
38const tab = atom({ plugin: 'docker-manager', key: 'tab' } as const, 'containers' as PaneTab)
39const containers = atom({ plugin: 'docker-manager', key: 'containers' } as const, {
40  kind: 'loading',
41} as ListState<Container>)
42const images = atom({ plugin: 'docker-manager', key: 'images' } as const, { kind: 'loading' } as ListState<Image>)
43const networks = atom({ plugin: 'docker-manager', key: 'networks' } as const, { kind: 'loading' } as ListState<Network>)
44const volumes = atom({ plugin: 'docker-manager', key: 'volumes' } as const, { kind: 'loading' } as ListState<Volume>)
45const expanded = atom({ plugin: 'docker-manager', key: 'expanded' } as const, [] as string[])
46const selected = atom({ plugin: 'docker-manager', key: 'selected' } as const, null as string | null)
47const logs = atom({ plugin: 'docker-manager', key: 'logs' } as const, null as Logs | null)
48const confirm = atom({ plugin: 'docker-manager', key: 'confirm' } as const, null as string | null)
49const busy = atom({ plugin: 'docker-manager', key: 'busy' } as const, null as string | null)
50const notice = atom({ plugin: 'docker-manager', key: 'notice' } as const, null as Notice | null)
51
52type Run = { exitCode: number; stdout: string; stderr: string }
53
54async function docker($: EngineInterface, argv: readonly string[], timeoutMs?: number): Promise<Run> {
55  try {
56    return await $.process.run(argv, timeoutMs === undefined ? {} : { timeoutMs })
57  } catch (error) {
58    return { exitCode: -1, stdout: '', stderr: String(error) }
59  }
60}
61
62async function readList<T>($: EngineInterface, argv: readonly string[], parse: (text: string) => T[]): Promise<ListState<T>> {
63  const run = await docker($, argv)
64  if (run.exitCode === 0) {
65    return { kind: 'list', items: parse(run.stdout) }
66  }
67
68  return { kind: 'error', message: describeError(run.stderr || run.stdout) }
69}
70
71// Writing an unchanged value would redraw the pane on every poll. Each atom is read and
72// written where it is named: the engine's scan does not follow one passed as a parameter.
73const changed = (before: unknown, after: unknown): boolean => JSON.stringify(before) !== JSON.stringify(after)
74
75async function loadContainers($: EngineInterface): Promise<void> {
76  const next = await readList($, CONTAINERS_ARGV, parseContainers)
77  if (changed(await read($, containers), next)) {
78    await update($, containers, () => next)
79  }
80  // A container removed outside the pane takes its open actions with it.
81  const id = await read($, selected)
82  if (id !== null && next.kind === 'list' && !next.items.some(c => c.id === id)) {
83    await update($, selected, () => null)
84  }
85}
86
87async function loadImages($: EngineInterface): Promise<void> {
88  const next = await readList($, IMAGES_ARGV, parseImages)
89  if (changed(await read($, images), next)) {
90    await update($, images, () => next)
91  }
92}
93
94async function loadNetworks($: EngineInterface): Promise<void> {
95  const next = await readList($, NETWORKS_ARGV, parseNetworks)
96  if (changed(await read($, networks), next)) {
97    await update($, networks, () => next)
98  }
99}
100
101async function loadVolumes($: EngineInterface): Promise<void> {
102  const next = await readList($, VOLUMES_ARGV, parseVolumes)
103  if (changed(await read($, volumes), next)) {
104    await update($, volumes, () => next)
105  }
106}
107
108// The containers always (the tab counts read them), the open tab's list besides.
109async function refreshShown($: EngineInterface): Promise<void> {
110  const shown = await read($, tab)
111  await Promise.all([
112    loadContainers($),
113    shown === 'images' ? loadImages($) : null,
114    shown === 'networks' ? loadNetworks($) : null,
115    shown === 'volumes' ? loadVolumes($) : null,
116  ])
117}
118
119async function refreshAll($: EngineInterface): Promise<void> {
120  await Promise.all([loadContainers($), loadImages($), loadNetworks($), loadVolumes($)])
121}
122
123// A result line goes after a while: an error stays longer, so it can be read.
124const NOTICE_MS = 5000
125const ERROR_NOTICE_MS = 15_000
126// Which notice is shown; a later one is not cleared by an earlier one's timer.
127let noticeSeq = 0
128
129async function say($: EngineInterface, value: Notice | null): Promise<void> {
130  noticeSeq += 1
131  const seq = noticeSeq
132  await update($, notice, () => value)
133  if (value !== null) {
134    void fadeNotice($, seq, value.isError ? ERROR_NOTICE_MS : NOTICE_MS)
135  }
136}
137
138async function fadeNotice($: EngineInterface, seq: number, ms: number): Promise<void> {
139  try {
140    await $.clock.sleep(ms)
141  } catch {
142    // The mod unloaded meanwhile, or no clock answers: the line stays until the next one.
143    return
144  }
145  if (seq === noticeSeq) {
146    await update($, notice, () => null)
147  }
148}
149
150type Reload = 'containers' | 'images' | 'networks' | 'volumes' | 'all'
151
152// The list an action changed, read again after it.
153async function reload($: EngineInterface, what: Reload): Promise<void> {
154  switch (what) {
155    case 'containers':
156      return loadContainers($)
157    case 'images':
158      return loadImages($)
159    case 'networks':
160      return loadNetworks($)
161    case 'volumes':
162      return loadVolumes($)
163    case 'all':
164      return refreshAll($)
165  }
166}
167
168// One action at a time: a press on a button drawn before the running action is ignored.
169async function act(
170  $: EngineInterface,
171  progress: string,
172  argv: readonly string[],
173  // What the result line says, from what docker printed.
174  done: string | ((stdout: string) => string),
175  after: Reload,
176): Promise<boolean> {
177  if ((await read($, busy)) !== null) {
178    return false
179  }
180  await update($, confirm, () => null)
181  await update($, busy, () => progress)
182  let isOk = false
183  try {
184    const run = await docker($, argv, ACTION_TIMEOUT_MS)
185    isOk = run.exitCode === 0
186    const text = typeof done === 'string' ? done : done(run.stdout)
187    await say($, isOk ? { text, isError: false } : { text: describeError(run.stderr || run.stdout), isError: true })
188  } finally {
189    // After the result is written, so the progress line gives way to it at once.
190    await update($, busy, () => null)
191  }
192  await reload($, after)
193
194  return isOk
195}
196
197const PROGRESS: Record<ContainerAction, string> = { start: 'Запускаю', stop: 'Останавливаю', restart: 'Перезапускаю' }
198const DONE: Record<ContainerAction, string> = { start: 'запущен', stop: 'остановлен', restart: 'перезапущен' }
199
200async function runContainers($: EngineInterface, action: ContainerAction, ids: string[], label: string): Promise<void> {
201  if (ids.length === 0) return
202  await act($, `${PROGRESS[action]} ${label}…`, containerArgv(action, ids), `${label}: ${DONE[action]}`, 'containers')
203}
204
205async function removeContainers($: EngineInterface, ids: string[], label: string): Promise<void> {
206  const isOk = await act($, `Удаляю ${label}…`, removeContainersArgv(ids), `${label} удалён`, 'containers')
207  const open = await read($, logs)
208  if (isOk && open !== null && ids.includes(open.id)) {
209    await update($, logs, () => null)
210  }
211}
212
213async function composeDown($: EngineInterface, project: string): Promise<void> {
214  await act($, `compose down ${project}…`, composeDownArgv(project), `${project}: down`, 'all')
215}
216
217async function loadLogs($: EngineInterface, id: string, name: string): Promise<void> {
218  const run = await docker($, logsArgv(id))
219  const next: Logs =
220    run.exitCode === 0
221      ? { kind: 'text', id, name, text: mergeLogs(run.stdout, run.stderr) }
222      : { kind: 'error', id, name, message: describeError(run.stderr || run.stdout) }
223  // The person may have closed the logs or opened another container's meanwhile.
224  const open = await read($, logs)
225  if (open !== null && open.id === id) {
226    await update($, logs, () => next)
227  }
228}
229
230async function showLogs($: EngineInterface, container: Container): Promise<void> {
231  await update($, logs, () => ({ kind: 'loading', id: container.id, name: container.name }))
232  await loadLogs($, container.id, container.name)
233}
234
235async function refreshLogs($: EngineInterface): Promise<void> {
236  const open = await read($, logs)
237  if (open !== null) {
238    await loadLogs($, open.id, open.name)
239  }
240}
241
242async function copyId($: EngineInterface, container: Container, surface: RenderSurface): Promise<void> {
243  const result = await $.ui.copy({ text: container.id, surface })
244  await say(
245    $,
246    result.isCopied
247      ? { text: `ID ${container.id} скопирован`, isError: false }
248      : { text: `Не удалось скопировать: ${result.reason}`, isError: true },
249  )
250}
251
252async function selectTab($: EngineInterface, next: PaneTab): Promise<void> {
253  await update($, tab, () => next)
254  await update($, confirm, () => null)
255  await refreshShown($)
256}
257
258async function toggleGroup($: EngineInterface, key: string): Promise<void> {
259  await update($, expanded, open => (open.includes(key) ? open.filter(k => k !== key) : [...open, key]))
260}
261
262async function select($: EngineInterface, id: string | null): Promise<void> {
263  await update($, selected, () => id)
264  await update($, confirm, () => null)
265}
266
267async function prune($: EngineInterface, what: 'images' | 'networks' | 'volumes'): Promise<void> {
268  switch (what) {
269    case 'images':
270      await act($, 'Удаляю висячие образы…', IMAGE_PRUNE_ARGV, out => reclaimed('Образы очищены', out), 'images')
271      return
272    case 'networks':
273      await act($, 'Удаляю неиспользуемые сети…', NETWORK_PRUNE_ARGV, 'Неиспользуемые сети удалены', 'networks')
274      return
275    case 'volumes':
276      await act($, 'Удаляю анонимные тома…', VOLUME_PRUNE_ARGV, out => reclaimed('Тома очищены', out), 'volumes')
277  }
278}
279
280async function openPane($: EngineInterface): Promise<boolean> {
281  const opened = await $.ui.open({ id: PANE, title: 'Docker' })
282  // After the open, so the pane shows while docker answers.
283  await refreshAll($)
284
285  return opened.isPlaced
286}
287
288export const register: Register = on => {
289  on('session.start', async ($, e, next) => {
290    await $.command.register({ name: 'docker', description: 'Открыть панель Docker' })
291    // State outlives a reload of the mod; an action it cut short never ends.
292    await update($, busy, () => null)
293    await update($, confirm, () => null)
294    // A result line from before the reload has no timer left to clear it.
295    await update($, notice, () => null)
296    // Docker is asked only while the pane is open: the containers change outside the session.
297    $.clock.every(POLL_MS, async () => {
298      if ((await $.ui.panes()).some(pane => pane.id === PANE) && (await read($, busy)) === null) {
299        await refreshShown($)
300      }
301    })
302
303    return next(e)
304  })
305
306  on('command.run', { command: 'docker' }, async $ => {
307    const isPlaced = await openPane($)
308
309    return {
310      text: isPlaced ? 'Панель Docker открыта.' : 'Панель Docker ждёт места: расширьте окно.',
311    }
312  })
313
314  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
315    const view = {
316      tab: await read($, tab),
317      containers: await read($, containers),
318      images: await read($, images),
319      networks: await read($, networks),
320      volumes: await read($, volumes),
321      expanded: await read($, expanded),
322      selected: await read($, selected),
323      logs: await read($, logs),
324      confirm: await read($, confirm),
325      busy: await read($, busy),
326      notice: await read($, notice),
327    }
328
329    return drawPane($.ui.resolve(e), view, {
330      refresh: () => void refreshAll($),
331      selectTab: next => void selectTab($, next),
332      toggleGroup: key => void toggleGroup($, key),
333      select: id => void select($, id),
334      run: (action, ids, label) => void runContainers($, action, ids, label),
335      ask: key => void update($, confirm, () => key),
336      cancel: () => void update($, confirm, () => null),
337      remove: (ids, label) => void removeContainers($, ids, label),
338      down: project => void composeDown($, project),
339      showLogs: container => void showLogs($, container),
340      refreshLogs: () => void refreshLogs($),
341      closeLogs: () => void update($, logs, () => null),
342      copyId: (container, surface) => void copyId($, container, surface),
343      removeImage: image =>
344        void act($, `Удаляю ${imageRef(image)}…`, removeImageArgv(image), `${imageRef(image)} удалён`, 'images'),
345      pruneImages: () => void prune($, 'images'),
346      removeNetwork: network =>
347        void act($, `Удаляю сеть ${network.name}…`, removeNetworkArgv(network), `Сеть ${network.name} удалена`, 'networks'),
348      pruneNetworks: () => void prune($, 'networks'),
349      removeVolume: volume =>
350        void act($, `Удаляю том ${shortName(volume.name)}…`, removeVolumeArgv(volume), `Том ${shortName(volume.name)} удалён`, 'volumes'),
351      pruneVolumes: () => void prune($, 'volumes'),
352    })
353  })
354}
355
hooks/docker.ts 243 lines
1import type { Container, Image, Network, Port, Volume } from '../types'
2
3// Fields split by tabs: a label's value may hold commas, which `{{json .}}` joins labels with.
4const PROJECT = '{{.Label "com.docker.compose.project"}}'
5
6export const CONTAINERS_ARGV = [
7  'docker',
8  'ps',
9  '-a',
10  '--format',
11  `{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}\t${PROJECT}\t{{.Label "com.docker.compose.service"}}`,
12] as const
13
14export const IMAGES_ARGV = [
15  'docker',
16  'images',
17  '--format',
18  '{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}\t{{.Containers}}',
19] as const
20
21export const NETWORKS_ARGV = [
22  'docker',
23  'network',
24  'ls',
25  '--format',
26  `{{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}\t${PROJECT}`,
27] as const
28
29export const VOLUMES_ARGV = ['docker', 'volume', 'ls', '--format', `{{.Name}}\t{{.Driver}}\t${PROJECT}`] as const
30
31// Only what nothing uses and nothing names: dangling images, unused networks, anonymous volumes.
32export const IMAGE_PRUNE_ARGV = ['docker', 'image', 'prune', '-f'] as const
33export const NETWORK_PRUNE_ARGV = ['docker', 'network', 'prune', '-f'] as const
34export const VOLUME_PRUNE_ARGV = ['docker', 'volume', 'prune', '-f'] as const
35
36// The networks Docker makes itself and refuses to remove.
37export const BUILT_IN_NETWORKS = new Set(['bridge', 'host', 'none'])
38
39// How many lines of a container's log the pane shows.
40export const LOG_LINES = 300
41// What one Code element draws at most.
42const LOG_CHARS = 100_000
43
44export type ContainerAction = 'start' | 'stop' | 'restart'
45
46export const containerArgv = (action: ContainerAction, ids: readonly string[]): string[] => [
47  'docker',
48  action,
49  ...ids,
50]
51
52// Forced: a running container is stopped first, as VS Code's "Remove" does after its question.
53export const removeContainersArgv = (ids: readonly string[]): string[] => ['docker', 'rm', '-f', ...ids]
54
55// Compose finds the project's containers, networks and volumes by its label: no file needed.
56export const composeDownArgv = (project: string): string[] => ['docker', 'compose', '-p', project, 'down']
57
58export const logsArgv = (id: string): string[] => ['docker', 'logs', '--timestamps', '--tail', String(LOG_LINES), id]
59
60// By name where it has one: removing by ID refuses an image tagged in several repositories.
61export const imageRef = (image: Image): string =>
62  image.repository === '<none>' || image.tag === '<none>' ? image.id : `${image.repository}:${image.tag}`
63
64export const removeImageArgv = (image: Image): string[] => ['docker', 'image', 'rm', imageRef(image)]
65
66export const removeNetworkArgv = (network: Network): string[] => ['docker', 'network', 'rm', network.id]
67
68export const removeVolumeArgv = (volume: Volume): string[] => ['docker', 'volume', 'rm', volume.name]
69
70const lines = (text: string): string[] =>
71  text
72    .split(/\r?\n/)
73    .map(line => line.trimEnd())
74    .filter(line => line !== '')
75
76const orNull = (text: string | undefined): string | null => (text === undefined || text === '' ? null : text)
77
78// `0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp, 6379/tcp`: IPv4 and IPv6 publish the same port twice.
79export function parsePorts(text: string): Port[] {
80  const ports: Port[] = []
81  for (const part of text.split(',')) {
82    const entry = part.trim()
83    if (entry === '') continue
84    const arrow = entry.indexOf('->')
85    if (arrow === -1) {
86      ports.push({ host: null, container: entry, url: null })
87      continue
88    }
89    const hostSide = entry.slice(0, arrow)
90    const container = entry.slice(arrow + 2)
91    const host = hostSide.slice(hostSide.lastIndexOf(':') + 1)
92    if (ports.some(port => port.host === host && port.container === container)) continue
93    const url = container.endsWith('/tcp') ? `http://localhost:${host}` : null
94    ports.push({ host, container, url })
95  }
96  // Published first: those are the ones a link opens.
97  return [...ports.filter(port => port.host !== null), ...ports.filter(port => port.host === null)]
98}
99
100export function parseContainers(text: string): Container[] {
101  return lines(text).map(line => {
102    const [id = '', name = '', image = '', state = '', status = '', ports = '', project, service] = line.split('\t')
103
104    return { id, name, image, state, status, ports: parsePorts(ports), project: orNull(project), service: orNull(service) }
105  })
106}
107
108export function parseImages(text: string): Image[] {
109  return lines(text).map(line => {
110    const [id = '', repository = '', tag = '', size = '', created = '', containers = ''] = line.split('\t')
111    const count = Number.parseInt(containers, 10)
112
113    return { id, repository, tag, size, created, containers: Number.isNaN(count) ? 0 : count }
114  })
115}
116
117export function parseNetworks(text: string): Network[] {
118  return lines(text).map(line => {
119    const [id = '', name = '', driver = '', scope = '', project] = line.split('\t')
120
121    return { id, name, driver, scope, project: orNull(project) }
122  })
123}
124
125export function parseVolumes(text: string): Volume[] {
126  return lines(text).map(line => {
127    const [name = '', driver = '', project] = line.split('\t')
128
129    return { name, driver, project: orNull(project) }
130  })
131}
132
133export type Group = {
134  // The compose project, '' for the containers run by hand.
135  key: string
136  title: string
137  containers: Container[]
138}
139
140// Compose projects by name, then the containers run by hand, as VS Code's tree lists them.
141export function groupContainers(containers: readonly Container[]): Group[] {
142  const projects = new Map<string, Container[]>()
143  const single: Container[] = []
144  for (const container of containers) {
145    if (container.project === null) {
146      single.push(container)
147    } else {
148      projects.set(container.project, [...(projects.get(container.project) ?? []), container])
149    }
150  }
151  const byName = (a: Container, b: Container) => (a.service ?? a.name).localeCompare(b.service ?? b.name)
152  const groups: Group[] = [...projects.keys()]
153    .sort((a, b) => a.localeCompare(b))
154    .map(key => ({ key, title: key, containers: [...(projects.get(key) ?? [])].sort(byName) }))
155  if (single.length > 0) {
156    groups.push({ key: '', title: 'Отдельные контейнеры', containers: [...single].sort(byName) })
157  }
158
159  return groups
160}
161
162export const isRunning = (container: Container): boolean =>
163  container.state === 'running' || container.state === 'restarting' || container.state === 'paused'
164
165// `2026-10-16T05:58:30.12Z message`: Docker trims the fraction's trailing zeros, so the stamps
166// are padded to nine digits before stdout and stderr are put back in one order.
167const STAMP = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2}) ?/
168
169type Line = { sortKey: string; time: string; text: string }
170
171function stamped(line: string, previous: string): Line {
172  const match = STAMP.exec(line)
173  if (match === null) {
174    return { sortKey: previous, time: '', text: line }
175  }
176  const [whole, seconds = '', fraction = '', zone = ''] = match
177  const sortKey = `${seconds}.${fraction.padEnd(9, '0').slice(0, 9)}${zone}`
178  const date = new Date(`${seconds}${zone}`)
179  const pad = (n: number) => String(n).padStart(2, '0')
180  const time = Number.isNaN(date.getTime())
181    ? seconds.slice(11)
182    : `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
183
184  return { sortKey, time, text: line.slice(whole.length) }
185}
186
187function stampAll(text: string): Line[] {
188  let previous = ''
189
190  return lines(text).map(line => {
191    const next = stamped(line, previous)
192    previous = next.sortKey
193
194    return next
195  })
196}
197
198// `docker logs` writes the container's stdout and stderr to its own two streams.
199export function mergeLogs(stdout: string, stderr: string): string {
200  const all = [...stampAll(stdout), ...stampAll(stderr)]
201  // Stable: lines of one stream keep their order within the same instant.
202  all.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0))
203  const text = all
204    .slice(-LOG_LINES)
205    .map(line => (line.time === '' ? line.text : `${line.time}  ${line.text}`))
206    .join('\n')
207
208  return text.length > LOG_CHARS ? text.slice(-LOG_CHARS) : text
209}
210
211// A volume compose or `docker run` made without a name is a 64-character hash.
212export const shortName = (name: string): string => (/^[0-9a-f]{64}$/.test(name) ? `${name.slice(0, 12)}…` : name)
213
214// `Total reclaimed space: 1.2GB`, the last line a prune prints.
215export function reclaimed(done: string, stdout: string): string {
216  const match = /Total reclaimed space:\s*(\S+)/.exec(stdout)
217
218  return match === null ? done : `${done}, освобождено ${match[1]}`
219}
220
221export const firstLine = (text: string): string => lines(text)[0] ?? ''
222
223// What Docker says when its engine is not up, on Windows and elsewhere.
224const DAEMON_DOWN = [
225  'cannot connect to the docker daemon',
226  'error during connect',
227  'docker daemon is not running',
228  'the system cannot find the file specified',
229  'is the docker daemon running',
230]
231
232export function describeError(stderr: string): string {
233  const text = stderr.toLowerCase()
234  if (DAEMON_DOWN.some(sign => text.includes(sign))) {
235    return 'Docker не запущен: запустите Docker Desktop'
236  }
237  if (text.includes('executable file not found') || text.includes('enoent') || text.includes('not found in path')) {
238    return 'Команда docker не найдена'
239  }
240  // Docker prefixes its answer with `Error response from daemon: `.
241  return firstLine(stderr).replace(/^Error response from daemon:\s*/i, '') || 'docker завершился с ошибкой'
242}
243
hooks/pane.tsx 412 lines
1import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
2
3import type { Container, Image, ListState, Logs, Network, Notice, PaneTab, Volume } from '../types'
4import { BUILT_IN_NETWORKS, groupContainers, isRunning, shortName, type ContainerAction, type Group } from './docker'
5
6// The elements every surface carries: the pane draws in the terminal and the desktop alike.
7type El = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Link' | 'Code'>
8
9export type View = {
10  tab: PaneTab
11  containers: ListState<Container>
12  images: ListState<Image>
13  networks: ListState<Network>
14  volumes: ListState<Volume>
15  expanded: string[]
16  selected: string | null
17  logs: Logs | null
18  confirm: string | null
19  busy: string | null
20  notice: Notice | null
21}
22
23export type Actions = {
24  refresh: () => void
25  selectTab: (tab: PaneTab) => void
26  toggleGroup: (key: string) => void
27  select: (id: string | null) => void
28  // `label` names what the action is run on, for the progress line and the result.
29  run: (action: ContainerAction, ids: string[], label: string) => void
30  ask: (key: string) => void
31  cancel: () => void
32  remove: (ids: string[], label: string) => void
33  down: (project: string) => void
34  showLogs: (container: Container) => void
35  refreshLogs: () => void
36  closeLogs: () => void
37  copyId: (container: Container, surface: RenderSurface) => void
38  removeImage: (image: Image) => void
39  pruneImages: () => void
40  removeNetwork: (network: Network) => void
41  pruneNetworks: () => void
42  removeVolume: (volume: Volume) => void
43  pruneVolumes: () => void
44}
45
46// The colour of a container's dot, as VS Code tints its icon.
47function stateColor(state: string): string {
48  switch (state) {
49    case 'running':
50      return 'success'
51    case 'paused':
52    case 'restarting':
53      return 'warning'
54    case 'dead':
55      return 'error'
56    default:
57      return 'inactive'
58  }
59}
60
61const groupKey = (key: string): string => (key === '' ? '_single' : key)
62
63export function drawPane(el: El, view: View, act: Actions) {
64  const { Box, Button, Code, Link, Text } = el
65
66  const card = (key: string, children: RenderChildren[]) => (
67    <Box key={key} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
68      {children}
69    </Box>
70  )
71
72  // A destructive press asks again in place: the button gives way to "Да / Отмена".
73  const confirmable = (key: string, label: string, question: string, onConfirm: () => void) =>
74    view.confirm === key ? (
75      <Box key={`ask:${key}`} gap={1} alignItems="center">
76        <Text color="warning">{question}</Text>
77        <Button key={`yes:${key}`} variant="primary" label="Да" onPress={onConfirm} />
78        <Button key={`no:${key}`} label="Отмена" onPress={act.cancel} />
79      </Box>
80    ) : (
81      <Button key={key} label={label} dimColor onPress={() => act.ask(key)} />
82    )
83
84  const statusLine =
85    view.busy !== null ? (
86      <Box key="notice">
87        <Text color="suggestion">⏳ {view.busy}</Text>
88      </Box>
89    ) : view.notice === null ? null : (
90      <Box key="notice">
91        <Text color={view.notice.isError ? 'error' : 'success'}>
92          {view.notice.isError ? '✕ ' : '✓ '}
93          {view.notice.text}
94        </Text>
95      </Box>
96    )
97
98  const count = <T,>(state: ListState<T>, title: string) =>
99    state.kind === 'list' ? `${title} ${state.items.length}` : title
100
101  const tabButton = (id: PaneTab, label: string) => (
102    <Button
103      key={`tab:${id}`}
104      label={label}
105      variant={view.tab === id ? 'primary' : 'secondary'}
106      onPress={() => act.selectTab(id)}
107    />
108  )
109
110  const header = (
111    <Box key="header" gap={1} alignItems="center" justifyContent="space-between">
112      <Box gap={1} flexWrap="wrap">
113        {tabButton('containers', count(view.containers, 'Контейнеры'))}
114        {tabButton('images', count(view.images, 'Образы'))}
115        {tabButton('networks', count(view.networks, 'Сети'))}
116        {tabButton('volumes', count(view.volumes, 'Тома'))}
117      </Box>
118      <Button key="refresh" label="Обновить" dimColor onPress={act.refresh} />
119    </Box>
120  )
121
122  // Loading and failure read the same on every tab.
123  const listOr = <T,>(state: ListState<T>, what: string, draw: (items: T[]) => RenderChildren[]) => {
124    if (state.kind === 'loading') {
125      return [<Text dimColor>Читаю {what}…</Text>]
126    }
127    if (state.kind === 'error') {
128      return [<Text color="error">{state.message}</Text>]
129    }
130    if (state.items.length === 0) {
131      return [<Text dimColor>Нет {what}</Text>]
132    }
133
134    return draw(state.items)
135  }
136
137  const portLinks = (container: Container) =>
138    container.ports
139      .filter(port => port.url !== null)
140      .map(port => (
141        <Link key={`port:${container.id}:${port.host}:${port.container}`} href={port.url ?? ''} label={`:${port.host}`} />
142      ))
143
144  const containerActions = (container: Container) => {
145    const name = container.name
146    const running = isRunning(container)
147
148    return (
149      <Box key={`actions:${container.id}`} gap={1} paddingLeft={4} flexWrap="wrap" alignItems="center">
150        {running ? (
151          <Button key={`stop:${container.id}`} label="Стоп" onPress={() => act.run('stop', [container.id], name)} />
152        ) : (
153          <Button
154            key={`start:${container.id}`}
155            variant="primary"
156            label="Старт"
157            onPress={() => act.run('start', [container.id], name)}
158          />
159        )}
160        {running && (
161          <Button
162            key={`restart:${container.id}`}
163            label="Рестарт"
164            onPress={() => act.run('restart', [container.id], name)}
165          />
166        )}
167        <Button key={`logs:${container.id}`} label="Логи" onPress={() => act.showLogs(container)} />
168        <Button key={`copy:${container.id}`} label="Копировать ID" dimColor onPress={press => act.copyId(container, press.surface)} />
169        {confirmable(`rm:${container.id}`, 'Удалить', `Удалить ${name}?`, () => act.remove([container.id], name))}
170      </Box>
171    )
172  }
173
174  const containerRow = (container: Container) => {
175    const isSelected = view.selected === container.id
176
177    return (
178      <Box key={`c:${container.id}`} flexDirection="column" gap={1}>
179        <Box gap={1} paddingLeft={2} alignItems="center">
180          <Text color={stateColor(container.state)}>●</Text>
181          <Button
182            key={`sel:${container.id}`}
183            label={container.service ?? container.name}
184            plain
185            onPress={() => act.select(isSelected ? null : container.id)}
186          />
187          <Text dimColor wrap="truncate">
188            {container.image}
189          </Text>
190          <Text dimColor wrap="truncate">
191            {container.status}
192          </Text>
193          {portLinks(container)}
194        </Box>
195        {isSelected && containerActions(container)}
196      </Box>
197    )
198  }
199
200  const groupRow = (group: Group) => {
201    const key = groupKey(group.key)
202    const isOpen = view.expanded.includes(group.key)
203    const ids = group.containers.map(c => c.id)
204    const running = group.containers.filter(isRunning)
205    const stopped = group.containers.filter(c => !isRunning(c))
206    const isProject = group.key !== ''
207
208    return (
209      <Box key={`g:${key}`} flexDirection="column" gap={1}>
210        {/* The name and count on the left, the project's actions on the right, as VS Code's row. */}
211        <Box gap={2} alignItems="center" justifyContent="space-between">
212          <Box gap={1} alignItems="center">
213            <Button
214              key={`group:${key}`}
215              label={`${isOpen ? '▾' : '▸'} ${group.title}`}
216              plain
217              onPress={() => act.toggleGroup(group.key)}
218            />
219            <Text color={running.length > 0 ? 'success' : 'inactive'}>
220              {running.length}/{group.containers.length}
221            </Text>
222          </Box>
223          <Box gap={1} alignItems="center" flexWrap="wrap" justifyContent="flex-end">
224          {isProject && stopped.length > 0 && (
225            <Button
226              key={`gstart:${key}`}
227              label="Старт"
228              dimColor
229              onPress={() => act.run('start', stopped.map(c => c.id), group.title)}
230            />
231          )}
232          {isProject && running.length > 0 && (
233            <Button
234              key={`gstop:${key}`}
235              label="Стоп"
236              dimColor
237              onPress={() => act.run('stop', running.map(c => c.id), group.title)}
238            />
239          )}
240          {isProject && running.length > 0 && (
241            <Button
242              key={`grestart:${key}`}
243              label="Рестарт"
244              dimColor
245              onPress={() => act.run('restart', ids, group.title)}
246            />
247          )}
248          {isProject &&
249            confirmable(`down:${group.key}`, 'Down', `compose down ${group.title}?`, () => act.down(group.key))}
250          </Box>
251        </Box>
252        {isOpen && group.containers.map(containerRow)}
253      </Box>
254    )
255  }
256
257  const logsCard = (logs: Logs) =>
258    card('logs', [
259      <Box key="logs-head" gap={1} alignItems="center" justifyContent="space-between">
260        <Text bold>Логи: {logs.name}</Text>
261        <Box gap={1}>
262          <Button key="logs-refresh" label="Обновить" dimColor onPress={act.refreshLogs} />
263          <Button key="logs-close" label="Закрыть" dimColor onPress={act.closeLogs} />
264        </Box>
265      </Box>,
266      logs.kind === 'loading' ? (
267        <Text dimColor>Читаю логи…</Text>
268      ) : logs.kind === 'error' ? (
269        <Text color="error">{logs.message}</Text>
270      ) : logs.text === '' ? (
271        <Text dimColor>Лог пуст</Text>
272      ) : (
273        <Code key="logs-text" source={logs.text} language="log" />
274      ),
275    ])
276
277  const containersTab = () => [
278    ...(view.logs !== null ? [logsCard(view.logs)] : []),
279    card('containers', [
280      // Rows apart, so the buttons of one row do not touch the next one's.
281      <Box key="groups" flexDirection="column" gap={1} paddingY={1}>
282        {listOr(view.containers, 'контейнеров', items => groupContainers(items).map(groupRow))}
283      </Box>,
284    ]),
285  ]
286
287  // One line a row: what it is on the left, cut short when narrow; its button on the right.
288  const listRow = (key: string, info: RenderChildren[], action: RenderChildren) => (
289    <Box key={key} gap={2} alignItems="center" justifyContent="space-between">
290      <Box gap={1} alignItems="center" flexShrink={1} minWidth={0} overflow="hidden">
291        {info}
292      </Box>
293      <Box flexShrink={0}>{action}</Box>
294    </Box>
295  )
296
297  // The tab's bulk action above, the rows apart, so no button touches the next row's.
298  const listCard = (key: string, tool: RenderChildren, rows: RenderChildren[]) =>
299    card(key, [
300      <Box key={`${key}-list`} flexDirection="column" gap={1} paddingY={1}>
301        <Box key={`${key}-tools`} justifyContent="flex-end">
302          {tool}
303        </Box>
304        {rows}
305      </Box>,
306    ])
307
308  const imagesTab = () =>
309    listCard(
310      'images',
311      confirmable('prune:images', 'Удалить висячие', 'Удалить образы без тега, которые никто не использует?', act.pruneImages),
312      listOr(view.images, 'образов', items =>
313        items.map(image =>
314          listRow(
315            `i:${image.id}:${image.repository}:${image.tag}`,
316            [
317              <Text bold wrap="truncate">
318                {image.repository}
319                <Text dimColor>:{image.tag}</Text>
320              </Text>,
321              <Text dimColor>{image.size}</Text>,
322              <Text dimColor wrap="truncate">
323                {image.created}
324              </Text>,
325              image.containers > 0 && <Text color="success">контейнеров: {image.containers}</Text>,
326            ],
327            confirmable(
328              `rmi:${image.id}:${image.repository}:${image.tag}`,
329              'Удалить',
330              `Удалить ${image.repository}:${image.tag}?`,
331              () => act.removeImage(image),
332            ),
333          ),
334        ),
335      ),
336    )
337
338  const networksTab = () =>
339    listCard(
340      'networks',
341      confirmable('prune:networks', 'Удалить неиспользуемые', 'Удалить сети без контейнеров?', act.pruneNetworks),
342      listOr(view.networks, 'сетей', items =>
343        items.map(network =>
344          listRow(
345            `n:${network.id}`,
346            [
347              <Text bold wrap="truncate">
348                {network.name}
349              </Text>,
350              <Text dimColor>{network.driver}</Text>,
351              <Text dimColor>{network.scope}</Text>,
352              network.project !== null && (
353                <Text color="suggestion" wrap="truncate">
354                  {network.project}
355                </Text>
356              ),
357            ],
358            BUILT_IN_NETWORKS.has(network.name)
359              ? null
360              : confirmable(`rmn:${network.id}`, 'Удалить', `Удалить сеть ${network.name}?`, () =>
361                  act.removeNetwork(network),
362                ),
363          ),
364        ),
365      ),
366    )
367
368  const volumesTab = () =>
369    listCard(
370      'volumes',
371      confirmable('prune:volumes', 'Удалить анонимные', 'Удалить безымянные тома без контейнеров?', act.pruneVolumes),
372      listOr(view.volumes, 'томов', items =>
373        items.map(volume =>
374          listRow(
375            `v:${volume.name}`,
376            [
377              <Text bold wrap="truncate">
378                {shortName(volume.name)}
379              </Text>,
380              <Text dimColor>{volume.driver}</Text>,
381              volume.project !== null && (
382                <Text color="suggestion" wrap="truncate">
383                  {volume.project}
384                </Text>
385              ),
386            ],
387            confirmable(`rmv:${volume.name}`, 'Удалить', `Удалить том ${shortName(volume.name)} с данными?`, () =>
388              act.removeVolume(volume),
389            ),
390          ),
391        ),
392      ),
393    )
394
395  const body =
396    view.tab === 'containers'
397      ? containersTab()
398      : view.tab === 'images'
399        ? [imagesTab()]
400        : view.tab === 'networks'
401          ? [networksTab()]
402          : [volumesTab()]
403
404  return (
405    <Box flexDirection="column" gap={1}>
406      {header}
407      {statusLine}
408      {body}
409    </Box>
410  )
411}
412
types/index.d.ts 85 lines
1// A port the container publishes on the host, or one it only exposes.
2export type Port = {
3  // `5432`, null when the port is not published.
4  host: string | null
5  // `5432/tcp`
6  container: string
7  // Where a published TCP port opens in a browser.
8  url: string | null
9}
10
11export type Container = {
12  id: string
13  name: string
14  image: string
15  // Docker's state: running, exited, paused, restarting, created, dead, removing.
16  state: string
17  // Docker's own line: `Up 2 hours`, `Exited (137) 4 days ago`.
18  status: string
19  ports: Port[]
20  // The compose project and service the container belongs to, null for one run by hand.
21  project: string | null
22  service: string | null
23}
24
25export type Image = {
26  id: string
27  repository: string
28  tag: string
29  size: string
30  // `7 days ago`
31  created: string
32  // How many containers use it.
33  containers: number
34}
35
36export type Network = {
37  id: string
38  name: string
39  driver: string
40  scope: string
41  project: string | null
42}
43
44export type Volume = {
45  name: string
46  driver: string
47  project: string | null
48}
49
50export type ListState<T> =
51  | { kind: 'loading' }
52  | { kind: 'error'; message: string }
53  | { kind: 'list'; items: T[] }
54
55export type PaneTab = 'containers' | 'images' | 'networks' | 'volumes'
56
57export type Notice = { text: string; isError: boolean }
58
59export type Logs =
60  | { kind: 'loading'; id: string; name: string }
61  | { kind: 'error'; id: string; name: string; message: string }
62  | { kind: 'text'; id: string; name: string; text: string }
63
64declare module 'claude-code' {
65  interface PluginState {
66    'docker-manager': {
67      tab: PaneTab
68      containers: ListState<Container>
69      images: ListState<Image>
70      networks: ListState<Network>
71      volumes: ListState<Volume>
72      // The groups shown open: a compose project's name, '' for the containers run by hand.
73      expanded: string[]
74      // The container whose actions are shown under its row.
75      selected: string | null
76      logs: Logs | null
77      // What waits for a second press: `rm:<id>`, `down:<project>`, `rmi:<id>`, ...
78      confirm: string | null
79      // The action running now, as the progress line reads it.
80      busy: string | null
81      notice: Notice | null
82    }
83  }
84}
85