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

Панель 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 не запущен, панель так и скажет.
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.
hooks/register.tsx 355 lines1import { 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}
355hooks/docker.ts 243 lines1import 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}
243hooks/pane.tsx 412 lines1import 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}
412types/index.d.ts 85 lines1// 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