SLOPSHOPPER

dev-up

Brings up a folder's dev stack from a YAML stack file: docker compose, detached dev servers, one-off tasks and check scripts, one pass at a time, with each…

newpanespinnerguardcommandtoast
v0.1.1no licenseupdated 2026-10-09chanlito/claude-mods/dev-up
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-up
│ ┃ dev-up ✕ › fix the failing auth test and add an audit log call │ ┃ Looking at the stack… │ ⏺ 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 │ │ › /dev-up │ ⎿ dev-up: No stack covers /work/app. A stack is /Users/dev/.claude │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dev-up
Looking at the stack…
README

dev-up

Brings up a folder's dev stack: Docker containers, dev servers, one-off tasks and anything else a script can check. You describe the stack once in a YAML file. /dev-up then runs one pass over it and returns at once:

UP      db: postgres healthy, mail up
STARTED web: pnpm dev  (log ~/.cache/dev-up/shop/web.log)
SKIP    codegen: waits for web; next run

shop  ● db  ◐ web  ○ codegen

Nothing waits. A service started in this pass doesn't count as up for the services after it, so they are skipped and the next /dev-up starts them. Running it again is both the health check and the way to finish.

Servers start detached (setsid), with a log and a pid file under ~/.cache/dev-up/<stack>/. The pid file also records which boot of the machine wrote it, so after a reboot a server reads as down rather than crashed, and stop never signals a pid that now belongs to some other process. They outlive the turn, the Claude session and a reload of the mod, and every session in the stack's folder sees the same ones. The stack's state shows at the end of the hint line under the prompt, after dev, one colored dot per service: green up, yellow starting, red broken, dim down. A task whose output exists shows a green ✓. The dots refresh every 30 seconds.

Installation

/plugin install dev-up --marketplace chanlito/claude-mods

Answer y to add the marketplace, then choose a scope (user is the usual one). The mod is active right away. It does nothing until a stack file covers the folder you're in, so the next step is writing one (see Config).

To run it from a clone while developing, without installing:

claude --plugin-dir ~/code/claude-mods/dev-up

or list the folder in CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json. Don't do both: an installed copy and a folder copy would load the mod twice.

It needs sh, plus docker for compose services and curl for report:. It finds ports with ss (Linux, WSL), or with lsof where ss is missing (macOS).

Config

The stacks folder

Stack files are read from ~/.claude/dev-stacks/ by default. To use another folder, change the mod's stacks option in /config, or set it in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "dev-up@claude-mods": { "options": { "stacks": "~/dotfiles/dev-stacks" } }
  }
}

The key is dev-up for a copy loaded with --plugin-dir.

A stack file

One file per stack in ~/.claude/dev-stacks/<name>.yml. A session uses the stack whose root: holds its folder (the deepest one wins).

name: shop
root: ~/code/shop          # the folder this stack covers
notes: shop/NOTES.md       # shown when a pass ends on a WARN (relative to this file)

services:
  db:
    compose: .             # a folder with docker-compose.yml (relative to root)
    ready: { healthy: [postgres], up: [mail] }

  web:
    cwd: web
    run: pnpm dev          # a server: up when its port listens
    port: 3000
    after: [db]

  codegen:
    cwd: web
    task: pnpm codegen     # a task: done once creates: exists
    creates: src/generated
    after: [web]

  seed:
    check: shop/seed.sh    # a script (relative to this file)
    probe: test -f .seeded # optional: a read-only command, exit 0 = up
    after: [web]

report:                    # probed after every pass
  web: http://localhost:3000/health

Each service has exactly one of compose, run, task or check.

  • compose: docker compose up -d in that folder. It is up when every container in ready.healthy reports healthy and every one in ready.up is running. When Docker Desktop on WSL restarts, a container with a bind mount (or a compose configs: file) can be left Exited (127), unable to start because the mount is gone. The pass sees that in docker inspect and recreates that container alone. Any other Exited (127) gets a WARN.
  • run: a server. It is up when port listens, or while its process lives if it has no port. A folder with package.json gets a WARN and is not started when it has no node_modules, or when its package-lock.json is newer than the last npm install (a pull added a package).
  • task: a one-off command that is done once creates exists.
  • check: an executable for anything a stack file can't say. It runs with the root as its working folder, DEV_UP_STACK and DEV_UP_ROOT set, and --dry-run on a dry run. It prints lines starting UP, STARTED, SKIP, WARN or ACTION, which /dev-up relays; any other line is shown as is. The worst prefix it printed sets the service's state. The script runs on every pass once what it waits for is up, since it is its own health check. Its last result is saved in ~/.cache/dev-up/<stack>/<service>.check, so a reload and every other session see it, until the machine restarts. Between passes the refresh never runs the script, because it may act. If you give it a probe:, a read-only command whose exit 0 means up, the refresh runs that instead, from the root.

The file is a subset of YAML: maps, lists, [a, b], { k: v }, quotes and # comments.

Commands

/dev-upone pass
/dev-up --dry-runwhat a pass would do; check scripts get --dry-run
/dev-up statuseach service's state and why
/dev-up panela pane with one cell per service in a grid: its state on the border, the end of its log inside, a restart button; refreshed every 3 seconds while open
/dev-up restart <svc> [args]stop and start one service; anything after its name is added to its command this once, so restart metro -- --clear runs npm start -- --clear. A compose service is recreated
/dev-up stop [<svc>]stop one service, or every server and task (containers stay up)
/dev-up logs <svc> [n]the last n lines of its log (40)
/dev-up use <dir>serve the servers and tasks of <dir>'s repo from that worktree
/dev-up restore [<svc>]serve them from their own folders again

Worktrees

/dev-up use ~/code/shop-wt/feat finds the servers and tasks whose folders are in the same git repo as that worktree. It restarts them from the matching folder in the worktree and keeps serving them from there on every later pass, until /dev-up restore. A server that waits on something not up yet starts on the next pass instead. Containers and check scripts stay where they are.

There is one port per server, so a move applies to every session, not just yours. The hint line shows it: web@feat means web is served from the feat worktree. If the worktree is removed, its services go back to their own folders on the next pass.

Stopping goes by process group and then by port, so a watcher's child that outlived its parent and still holds the port is stopped too.

Claude gets the same commands as the dev_up tool. Its description tells Claude to use the tool rather than starting servers from Bash, where they would die with the turn.

FAQ

Why doesn't /dev-up wait until everything is up? A pass that waits would hold the turn for minutes while containers turn healthy and emulators boot. A pass returns right away and says what it skipped. Run /dev-up again when you're ready. Claude does the same with the dev_up tool, calling status again after a pause.

Do the servers stop when I close Claude? No. They run in a session of their own and keep going until you run /dev-up stop, or until they exit by themselves. Any Claude session in the stack's folder sees them, and a pass leaves them alone.

Where is a server's output? In ~/.cache/dev-up/<stack>/<service>.log. /dev-up logs <service> 100 shows the end of it. For a compose service, it shows docker compose logs.

I started a server myself in a terminal. Will /dev-up start a second one? No. A server counts as up when its port listens, whoever started it. stop and restart stop whatever holds that port, though, so they reach your terminal's server too.

It says "No stack covers …" No stack file's root: holds the session's folder. Check the root:, and that the file is in the stacks folder and ends in .yml or .yaml. A file that fails to load is named at the end of the message, with what is wrong in it.

npm or another command is "not found" in the log. Commands run with the environment Claude Code was started with. A Claude started from your shell has your PATH; one started some other way may not. Put what the command needs in run: itself, for example run: . ~/.nvm/nvm.sh && npm run dev, or give the full path.

I already have a /dev-up skill or command. The mod leaves the name to it and says so once. The dev_up tool and the status line still work. Rename or remove the other one to get the command.

Why YAML without anchors or multi-line strings? A mod runs without npm packages, so it carries its own small parser. It reads what a stack file needs: maps, lists, [a, b], { k: v }, quotes and comments. Anything else is an error that names the line.

Can two sessions each serve their own worktree? Not on the same port. use moves a server for everyone, so one session's move replaces another's. Run one branch at a time, or give the second checkout its own stack file with other ports.

When do the dots update? Every 30 seconds, and after each /dev-up or dev_up call. Check scripts run only during a pass, so a check service shows what it said last time.

Does it run on Windows? Under WSL, yes. On native Windows, no: it needs sh.

Develop

claude plugin validate dev-up, claude plugin test dev-up, and dev-up/tests/no-project-names.sh, which fails if one of your projects' names reaches the mod. It takes the names from your own stack files (each stack's name, root folder, and cwd: and compose: folders), plus <stacks>/private-words, one word per line, for names a stack file doesn't spell out. So the check names no project itself. Those names belong in the project's stack file.

Source 4 files
hooks/register.tsx 837 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Cell, Dots, Panel } from "../types";
5
6import {
7  applyOverrides,
8  composeRows,
9  composeState,
10  exited127,
11  formatStep,
12  parseLine,
13  parseStack,
14  pickStack,
15  plan,
16  rebase,
17  resolvePath,
18  stateOfLines,
19  statusLine,
20  StackError,
21  type Observed,
22  type Overrides,
23  type Prefix,
24  type Seen,
25  type Service,
26  type Stack,
27  type Step,
28} from "./stack";
29
30/** How often the hint line's dots look again. */
31const REFRESH_MS = 30_000;
32const TOOL = "dev_up";
33
34/**
35 * The machine's boot id (Linux, macOS): a pid written under another boot names
36 * some other process, or none. Clock times can't say it: a WSL guest's boot
37 * time moves when its clock is corrected after the host sleeps.
38 */
39const BOOT_ID = String.raw`boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || sysctl -n kern.bootsessionuuid 2>/dev/null)`;
40
41/**
42 * Prints `BOOT <id>`, `PORT <n>` per listening TCP port, and `PID <service>
43 * alive|dead|stale` per pid file: its process group, or stale when the file is
44 * from before the machine last started.
45 */
46const PROBE = String.raw`
47dir="$1"
48${BOOT_ID}
49[ -n "$boot" ] && echo "BOOT $boot"
50if command -v ss >/dev/null 2>&1; then ss -ltnH 2>/dev/null | awk '{print $4}'
51else lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $9}'; fi | sed -n 's/.*:\([0-9][0-9]*\)$/PORT \1/p' | sort -u
52for f in "$dir"/*.pid; do
53  [ -e "$f" ] || continue
54  n=$(basename "$f" .pid); p=$(sed -n 1p "$f"); b=$(sed -n 2p "$f")
55  if [ -n "$b" ] && [ "$b" != "$boot" ]; then echo "PID $n stale"; continue; fi
56  # The group, not only its first process: a watcher outlives the shell it started from.
57  if [ -n "$p" ] && { kill -0 "-$p" 2>/dev/null || kill -0 "$p" 2>/dev/null; }; then echo "PID $n alive"; else echo "PID $n dead"; fi
58done
59`;
60
61/**
62 * Starts a command detached, in a session of its own (setsid) so it outlives
63 * the turn, the Claude session and a reload of this module, which kills the
64 * module's own children. Output goes to the log; the pid to the pid file.
65 */
66const START = String.raw`
67cwd="$1"; cmd="$2"; log="$3"; pidf="$4"
68mkdir -p "$(dirname "$log")" || exit 1
69cd "$cwd" || { echo "no folder $cwd" >&2; exit 2; }
70printf '\n[dev-up] %s  %s\n' "$(date '+%F %T')" "$cmd" >> "$log"
71if command -v setsid >/dev/null 2>&1; then
72  setsid sh -c "$cmd" >> "$log" 2>&1 < /dev/null &
73else
74  nohup sh -c "$cmd" >> "$log" 2>&1 < /dev/null &
75fi
76p=$!
77${BOOT_ID}
78printf '%s\n%s\n' "$p" "$boot" > "$pidf"
79`;
80
81/**
82 * Stops by process group and by port: a dev server's child (a watcher's
83 * compiled main) can outlive its parent and keep the port, and a watcher can
84 * outlive the TERM that ends the shell it started from. TERM, up to five
85 * seconds while anything in the group or on the port lives, then KILL.
86 */
87const STOP = String.raw`
88pidf="$1"; port="$2"; log="$3"
89p=""; [ -f "$pidf" ] && p=$(sed -n 1p "$pidf")
90# A pid from another boot is some other process now: only the port is stopped.
91${BOOT_ID}
92[ -f "$pidf" ] && [ -n "$(sed -n 2p "$pidf")" ] && [ "$(sed -n 2p "$pidf")" != "$boot" ] && p=""
93holders() {
94  [ -n "$port" ] || return 0
95  if command -v ss >/dev/null 2>&1; then ss -ltnpH "sport = :$port" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2
96  else lsof -t -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null; fi | sort -u
97}
98alive() { [ -n "$p" ] && { kill -0 "-$p" 2>/dev/null || kill -0 "$p" 2>/dev/null; }; }
99[ -n "$p" ] && { kill -TERM "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null; }
100h=$(holders); [ -n "$h" ] && kill -TERM $h 2>/dev/null
101i=0
102while [ $i -lt 20 ] && { alive || [ -n "$(holders)" ]; }; do sleep 0.25; i=$((i+1)); done
103h=$(holders); [ -n "$h" ] && kill -KILL $h 2>/dev/null
104[ -n "$p" ] && { kill -KILL "-$p" 2>/dev/null; kill -KILL "$p" 2>/dev/null; }
105rm -f "$pidf"
106printf '[dev-up] %s  stopped\n' "$(date '+%F %T')" >> "$log"
107exit 0
108`;
109
110const HELP = [
111  "/dev-up                       one pass: start what is down, skip what waits",
112  "/dev-up --dry-run             print what a pass would do",
113  "/dev-up status                each service's state",
114  "/dev-up restart <svc> [args]  stop and start one service; args are added to its command this once",
115  "/dev-up stop [<svc>]          stop one service, or every server and task",
116  "/dev-up logs <svc> [n]        the last n lines of its log (40)",
117  "/dev-up use <dir>             serve the servers of <dir>'s repo from that worktree",
118  "/dev-up restore [<svc>]       serve them from their own folders again",
119  "/dev-up panel                 every service and the end of its log, in a grid",
120].join("\n");
121
122type Run = { exitCode: number; stdout: string; stderr: string };
123
124async function run($: EngineInterface, argv: string[], init: { cwd?: string; timeoutMs?: number; env?: Record<string, string> } = {}): Promise<Run> {
125  try {
126    return await $.process.run(argv, init);
127  } catch (error) {
128    return { exitCode: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
129  }
130}
131
132const lastLine = (text: string) => text.trim().split("\n").at(-1)?.trim() ?? "";
133
134async function homeOf($: EngineInterface) {
135  return (await $.env.get("HOME")) ?? "";
136}
137
138const expandHome = (path: string, home: string) => path.replace(/^~(?=$|\/)/, home).replace(/\/+$/, "");
139
140/**
141 * `stack` is what runs: `base` with `/dev-up use` overrides applied. `base` is
142 * the stack file as written, for use and restore.
143 */
144type Found = { stack?: Stack; base?: Stack; overrides: Overrides; errors: string[]; folder: string; cwd: string };
145
146async function findStack($: EngineInterface, folderSetting: string): Promise<Found> {
147  const home = await homeOf($);
148  const folder = expandHome(folderSetting, home);
149  const cwd = await $.session.cwd();
150  const errors: string[] = [];
151  const stacks: Stack[] = [];
152  const entries = await $.fs.list(folder).catch(() => []);
153  for (const entry of entries) {
154    if (entry.kind !== "file" || !/\.ya?ml$/.test(entry.name)) continue;
155    const file = `${folder}/${entry.name}`;
156    try {
157      stacks.push(parseStack(await $.fs.read(file), file, home));
158    } catch (error) {
159      errors.push(`${file}: ${error instanceof StackError || error instanceof Error ? error.message : String(error)}`);
160    }
161  }
162  const base = pickStack(stacks, cwd);
163  if (!base) return { errors, folder, cwd, overrides: {} };
164  const overrides = await readOverrides($, home, base);
165  return { stack: applyOverrides(base, overrides), base, overrides, errors, folder, cwd };
166}
167
168const stateDir = (home: string, stack: Stack) => `${home}/.cache/dev-up/${stack.name}`;
169const overridesFile = (home: string, stack: Stack) => `${stateDir(home, stack)}/use.json`;
170
171/** The `/dev-up use` overrides on disk; one whose folder is gone (a removed worktree) is dropped. */
172async function readOverrides($: EngineInterface, home: string, stack: Stack): Promise<Overrides> {
173  let saved: unknown;
174  try {
175    saved = JSON.parse(await $.fs.read(overridesFile(home, stack)));
176  } catch {
177    return {};
178  }
179  const out: Overrides = {};
180  if (!saved || typeof saved !== "object") return out;
181  for (const [name, dir] of Object.entries(saved as Record<string, unknown>))
182    if (typeof dir === "string" && stack.services.some((s) => s.name === name) && (await $.fs.exists(dir))) out[name] = dir;
183  return out;
184}
185
186/**
187 * A service's checkout: its top folder and the worktrees git lists for its
188 * repo. Git runs only in the service's own folder, from the stack file, never
189 * in a folder a prompt or the model named: a repo's config can make git run
190 * commands.
191 */
192async function checkoutOf($: EngineInterface, dir: string): Promise<{ top: string; worktrees: string[] } | undefined> {
193  const top = await run($, ["git", "-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel"], { timeoutMs: 10_000 });
194  const list = await run($, ["git", "-C", dir, "worktree", "list", "--porcelain"], { timeoutMs: 10_000 });
195  if (top.exitCode !== 0 || list.exitCode !== 0 || !top.stdout.trim()) return undefined;
196  const worktrees = list.stdout
197    .split("\n")
198    .filter((l) => l.startsWith("worktree "))
199    .map((l) => l.slice("worktree ".length).replace(/\/+$/, ""));
200  return { top: top.stdout.trim(), worktrees };
201}
202const logOf = (home: string, stack: Stack, s: Service) => `${stateDir(home, stack)}/${s.name}.log`;
203const pidOf = (home: string, stack: Stack, s: Service) => `${stateDir(home, stack)}/${s.name}.pid`;
204
205const dots = atom({ plugin: "dev-up", key: "dots" } as const, null);
206
207/** Saves what the hint line draws; written only when it changed, so an idle refresh redraws nothing. */
208async function show($: EngineInterface, stack: Stack | undefined, seen: Observed = {}) {
209  const next: Dots = stack
210    ? {
211        stack: stack.name,
212        services: stack.services.map((s) => ({
213          name: s.name,
214          state: seen[s.name]?.state ?? "down",
215          ...(s.kind === "task" && seen[s.name]?.state === "up" ? { done: true } : {}),
216          ...(s.servedFrom ? { from: s.servedFrom } : {}),
217        })),
218      }
219    : null;
220  if (JSON.stringify(await read($, dots)) === JSON.stringify(next)) return;
221  await update($, dots, () => next);
222}
223
224const COLOR = { up: "success", starting: "warning", broken: "error" } as const;
225const GLYPH = { up: "●", starting: "◐", down: "○", broken: "✕" } as const;
226
227const checkFile = (home: string, stack: Stack, s: Service) => `${stateDir(home, stack)}/${s.name}.check`;
228
229/** This boot's id, or undefined where the machine gives none. */
230async function bootId($: EngineInterface): Promise<string | undefined> {
231  const r = await run($, ["sh", "-c", `${BOOT_ID}\necho "$boot"`], { timeoutMs: 5_000 });
232  return r.stdout.trim() || undefined;
233}
234
235/**
236 * What a check script last said, kept on disk so a reload and every other
237 * session see it too. One said before the machine last started is no answer.
238 */
239async function savedCheck($: EngineInterface, home: string, stack: Stack, s: Service, boot?: string): Promise<Seen | undefined> {
240  try {
241    const saved = JSON.parse(await $.fs.read(checkFile(home, stack, s))) as Seen & { boot?: string };
242    if (boot && saved.boot && saved.boot !== boot) return undefined;
243    delete saved.boot;
244    return ["up", "starting", "down", "broken"].includes(saved.state) ? saved : undefined;
245  } catch {
246    return undefined;
247  }
248}
249
250async function observe($: EngineInterface, stack: Stack): Promise<Observed> {
251  const home = await homeOf($);
252  const probe = await run($, ["sh", "-c", PROBE, "sh", stateDir(home, stack)], { timeoutMs: 10_000 });
253  const ports = new Set<number>();
254  // A pid file from before a reboot (stale) says nothing: that service is simply down.
255  const pids = new Map<string, boolean>();
256  let boot: string | undefined;
257  for (const line of probe.stdout.split("\n")) {
258    const [kind, a, b] = line.trim().split(/\s+/);
259    if (kind === "PORT") ports.add(Number(a));
260    if (kind === "PID" && a && b !== "stale") pids.set(a, b === "alive");
261    if (kind === "BOOT" && a) boot = a;
262  }
263
264  const seen: Observed = {};
265  await Promise.all(
266    stack.services.map(async (s) => {
267      const log = logOf(home, stack, s);
268      const alive = pids.get(s.name);
269      let entry: Seen;
270      if (s.kind === "compose") {
271        const ps = await run($, ["docker", "compose", "ps", "-a", "--format", "json"], { cwd: s.dir, timeoutMs: 20_000 });
272        if (ps.exitCode !== 0)
273          entry = {
274            state: "broken",
275            why: /cannot connect|daemon|docker\.sock|not running/i.test(ps.stderr)
276              ? "Docker is not running: start it, then run /dev-up again"
277              : `docker compose ps failed: ${lastLine(ps.stderr)}`,
278          };
279        else {
280          try {
281            entry = composeState(s, ps.stdout, await exitErrors($, s, ps.stdout));
282          } catch {
283            entry = { state: "broken", why: "could not read docker compose ps" };
284          }
285        }
286      } else if (s.kind === "check") {
287        // Between passes: the probe, if the stack gives one, beside what the script last said.
288        const saved = await savedCheck($, home, stack, s, boot);
289        if (!s.probe) entry = saved ?? { state: "down" };
290        else {
291          const ok = (await run($, ["sh", "-c", s.probe], { cwd: stack.root, timeoutMs: 10_000 })).exitCode === 0;
292          entry = ok
293            ? saved?.state === "broken" ? saved : { state: "up" }
294            : saved?.state === "starting" ? saved : { state: "down" };
295        }
296      } else if (s.kind === "task") {
297        if (await $.fs.exists(s.creates!)) entry = { state: "up" };
298        else if (alive === true) entry = { state: "starting" };
299        else if (alive === false) entry = { state: "broken", why: `exited without making ${s.creates}; log: ${log}` };
300        else entry = { state: "down" };
301      } else {
302        if (s.port ? ports.has(s.port) : alive === true) entry = { state: "up" };
303        else if (alive === true) entry = { state: "starting" };
304        else if (alive === false) entry = { state: "broken", why: `exited since it was started; log: ${log}` };
305        else entry = { state: "down" };
306      }
307      if ((s.kind === "server" || s.kind === "task") && entry.state !== "up" && (await $.fs.exists(`${s.dir}/package.json`)))
308        entry.needsInstall = await installGap($, s.dir);
309      seen[s.name] = entry;
310    }),
311  );
312  return seen;
313}
314
315/** Why each container that exited 127 did, from Docker: `.State.Error` by compose service. */
316async function exitErrors($: EngineInterface, s: Service, psJson: string): Promise<Record<string, string>> {
317  const ids = exited127(composeRows(psJson))
318    .map((r) => r.ID)
319    .filter((id): id is string => !!id);
320  if (!ids.length) return {};
321  const format = '{{index .Config.Labels "com.docker.compose.service"}}{{"\\t"}}{{.State.Error}}';
322  const r = await run($, ["docker", "inspect", "--format", format, ...ids], { cwd: s.dir, timeoutMs: 20_000 });
323  const errors: Record<string, string> = {};
324  for (const line of r.stdout.split("\n")) {
325    const [service, ...error] = line.split("\t");
326    if (service && error.join("\t").trim()) errors[service] = error.join("\t").trim();
327  }
328  return errors;
329}
330
331/** One shell word, quoted so the shell reads it as text and runs nothing in it. */
332const shellWord = (word: string) => `'${word.replace(/'/g, `'\\''`)}'`;
333
334/**
335 * `extra` is added to the command for this start only (`-- --clear`), each word
336 * quoted: it comes from a prompt or the model's tool call, never from the stack file.
337 */
338/**
339 * Why `dir` needs an install before it can start, if it does: no node_modules,
340 * or a lockfile newer than the install npm last recorded (a pull added a package).
341 */
342async function installGap($: EngineInterface, dir: string): Promise<string | undefined> {
343  if (!(await $.fs.exists(`${dir}/node_modules`))) return `no node_modules in ${dir}`;
344  const stat = (path: string) => $.fs.stat(path).catch(() => undefined);
345  const [lock, installed] = await Promise.all([stat(`${dir}/package-lock.json`), stat(`${dir}/node_modules/.package-lock.json`)]);
346  if (lock && installed && lock.mtimeMs > installed.mtimeMs) return `package-lock.json in ${dir} changed since the last npm install`;
347  return undefined;
348}
349
350async function start($: EngineInterface, stack: Stack, s: Service, extra = ""): Promise<Run> {
351  const home = await homeOf($);
352  const words = extra.split(/\s+/).filter(Boolean).map(shellWord);
353  const command = [s.kind === "task" ? s.task! : s.run!, ...words].join(" ");
354  return run($, ["sh", "-c", START, "sh", s.dir, command, logOf(home, stack, s), pidOf(home, stack, s)]);
355}
356
357async function stop($: EngineInterface, stack: Stack, s: Service): Promise<Run> {
358  const home = await homeOf($);
359  return run($, ["sh", "-c", STOP, "sh", pidOf(home, stack, s), s.port ? String(s.port) : "", logOf(home, stack, s)], { timeoutMs: 15_000 });
360}
361
362/** Runs a check script; its UP / STARTED / SKIP / WARN lines are relayed under the service's name. */
363async function runCheck($: EngineInterface, stack: Stack, s: Service, dryRun: boolean): Promise<{ lines: string[]; seen: Seen }> {
364  const r = await run($, dryRun ? [s.check!, "--dry-run"] : [s.check!], {
365    cwd: stack.root,
366    timeoutMs: 300_000,
367    env: { DEV_UP_STACK: stack.name, DEV_UP_ROOT: stack.root },
368  });
369  const lines: string[] = [];
370  const prefixes: Prefix[] = [];
371  for (const raw of r.stdout.split("\n")) {
372    if (!raw.trim()) continue;
373    const line = parseLine(raw);
374    if (line) {
375      prefixes.push(line.prefix);
376      lines.push(formatStep({ prefix: line.prefix, text: `${s.name}: ${line.text}` }));
377    } else lines.push(`${" ".repeat(8)}${raw.trimEnd()}`);
378  }
379  if (r.exitCode !== 0 && prefixes.length === 0) {
380    prefixes.push("WARN");
381    lines.push(formatStep({ prefix: "WARN", text: `${s.name}: ${s.check} exited ${r.exitCode}: ${lastLine(r.stderr) || "no output"}` }));
382  }
383  const seen: Seen = { state: stateOfLines(prefixes) };
384  // The lines too, for the panel: a check script keeps no log of its own.
385  const boot = await bootId($);
386  const saved = { ...seen, ...(boot ? { boot } : {}), lines: r.stdout.split("\n").filter((l) => l.trim()).slice(-PANEL_LINES) };
387  if (!dryRun) await $.fs.write(checkFile(await homeOf($), stack, s), `${JSON.stringify(saved)}\n`).catch(() => {});
388  return { lines, seen };
389}
390
391async function report($: EngineInterface, stack: Stack): Promise<string | undefined> {
392  if (stack.report.length === 0) return undefined;
393  const codes = await Promise.all(
394    stack.report.map(async ({ name, url }) => {
395      const r = await run($, ["curl", "-s", "-m", "3", "-o", "/dev/null", "-w", "%{http_code}", url], { timeoutMs: 6_000 });
396      return `${name} ${r.stdout.trim() || "000"}`;
397    }),
398  );
399  return `${"report".padEnd(8)}${codes.join("  ")}`;
400}
401
402async function pass($: EngineInterface, stack: Stack, dryRun: boolean): Promise<{ lines: string[]; seen: Observed }> {
403  const home = await homeOf($);
404  const seen = await observe($, stack);
405  const steps: Step[] = plan(stack, seen);
406  const lines: string[] = [];
407  for (const step of steps) {
408    const s = stack.services.find((x) => x.name === step.service)!;
409    if (!step.action) {
410      lines.push(formatStep(step));
411      continue;
412    }
413    if (step.action === "check") {
414      const checked = await runCheck($, stack, s, dryRun);
415      lines.push(...checked.lines);
416      if (!dryRun) seen[s.name] = checked.seen;
417      continue;
418    }
419    if (dryRun) {
420      const what = step.command ? step.command.join(" ") : step.text.slice(s.name.length + 2);
421      lines.push(formatStep({ prefix: "ACTION", text: `${s.name}: ${what}  (in ${s.dir})` }));
422      continue;
423    }
424    const r = step.command ? await run($, step.command, { cwd: s.dir, timeoutMs: 300_000 }) : await start($, stack, s);
425    if (r.exitCode === 0) {
426      const where = step.action === "start" ? `  (log ${logOf(home, stack, s)})` : "";
427      lines.push(formatStep({ prefix: "STARTED", text: `${step.text}${where}` }));
428      seen[s.name] = { state: "starting" };
429    } else lines.push(formatStep({ prefix: "WARN", text: `${s.name}: could not start: ${lastLine(r.stderr) || `exit ${r.exitCode}`}` }));
430  }
431  const reported = dryRun ? undefined : await report($, stack);
432  if (reported) lines.push(reported);
433  if (stack.notes && lines.some((l) => /^(WARN|STOP)\s/.test(l))) lines.push(`Notes for this stack: ${stack.notes}`);
434  return { lines, seen };
435}
436
437function serviceOf(stack: Stack, name: string | undefined): Service | string {
438  if (!name) return `Name a service: ${stack.services.map((s) => s.name).join(", ")}.`;
439  return stack.services.find((s) => s.name === name) ?? `No service "${name}" in ${stack.name}. It has ${stack.services.map((s) => s.name).join(", ")}.`;
440}
441
442/** The whole command, shared by /dev-up and the model's tool. */
443async function dispatch($: EngineInterface, folderSetting: string, args: string): Promise<string> {
444  const found = await findStack($, folderSetting);
445  const { stack } = found;
446  const broken = found.errors.length ? `\n\nStack files that did not load:\n${found.errors.join("\n")}` : "";
447  if (!stack)
448    return `No stack covers ${found.cwd}. A stack is ${found.folder}/<name>.yml with a root: that holds this folder.${broken}`;
449
450  const words = args.trim().split(/\s+/).filter(Boolean);
451  const [verb = "", name, extra] = words;
452  /** Everything after the service's name, for restart. */
453  const rest = words.slice(2).join(" ");
454  const home = await homeOf($);
455
456  if (verb === "" || verb === "up" || verb === "--dry-run" || verb === "dry-run") {
457    const dryRun = verb !== "" && verb !== "up";
458    const { lines, seen } = await pass($, stack, dryRun);
459    if (!dryRun) await show($, stack, seen);
460    return [...lines, "", statusLine(stack, seen)].join("\n") + broken;
461  }
462  if (verb === "status") {
463    const seen = await observe($, stack);
464    await show($, stack, seen);
465    return stack.services
466      .map(
467        (s) =>
468          `${(seen[s.name]?.state ?? "down").padEnd(9)}${s.name}${s.servedFrom ? `  from ${s.dir}` : ""}${seen[s.name]?.why ? `  ${seen[s.name]!.why}` : ""}`,
469      )
470      .join("\n");
471  }
472  if (verb === "help") return HELP;
473  if (verb === "panel") {
474    await openPanel($, folderSetting);
475    return "Opened the dev stack panel: every service and the end of its log, refreshed every 3 seconds.";
476  }
477  if (verb === "logs") {
478    const s = serviceOf(stack, name);
479    if (typeof s === "string") return s;
480    const n = String(Math.min(500, Math.max(1, Number(extra) || 40)));
481    if (s.kind === "check") return `${s.name} is a check script; it keeps no log. Run /dev-up to see what it says.`;
482    const r =
483      s.kind === "compose"
484        ? await run($, ["docker", "compose", "logs", "--no-color", "--tail", n], { cwd: s.dir })
485        : await run($, ["tail", "-n", n, logOf(home, stack, s)]);
486    return r.exitCode === 0 ? r.stdout.trimEnd() || "(empty)" : `No log for ${s.name} yet.`;
487  }
488  if (verb === "stop") {
489    if (!name) {
490      const targets = stack.services.filter((s) => s.kind === "server" || s.kind === "task");
491      for (const s of targets) await stop($, stack, s);
492      return `Stopped ${targets.map((s) => s.name).join(", ")}. Containers are left running.`;
493    }
494    const s = serviceOf(stack, name);
495    if (typeof s === "string") return s;
496    if (s.kind === "check") return `${s.name} is a check script; there is nothing to stop.`;
497    if (s.kind === "compose") {
498      const r = await run($, ["docker", "compose", "stop"], { cwd: s.dir, timeoutMs: 120_000 });
499      return r.exitCode === 0 ? `Stopped ${s.name}'s containers.` : `docker compose stop failed: ${lastLine(r.stderr)}`;
500    }
501    await stop($, stack, s);
502    return `Stopped ${s.name}.`;
503  }
504  if (verb === "use" || verb === "restore") {
505    const base = found.base!;
506    const next: Overrides = { ...found.overrides };
507    if (verb === "use") {
508      if (!name) return "Name the worktree: /dev-up use <dir>.";
509      const target = resolvePath(name, found.cwd, home);
510      let matched = 0;
511      for (const s of base.services) {
512        if (s.kind !== "server" && s.kind !== "task") continue;
513        const own = await checkoutOf($, s.dir);
514        // Only a worktree git lists for this service's repo, the deepest that holds the target.
515        const into = own?.worktrees
516          .filter((w) => target === w || target.startsWith(`${w}/`))
517          .sort((a, b) => b.length - a.length)[0];
518        if (!own || !into) continue;
519        const dir = rebase(s.dir, own.top, into);
520        if (!dir) continue;
521        matched++;
522        if (dir === s.dir) delete next[s.name];
523        else next[s.name] = dir;
524      }
525      if (!matched) return `${target} is not a worktree of any repo a server or task in ${base.name} runs from (git worktree list).`;
526    } else if (name) {
527      if (!(name in next)) return `${name} is served from its own folder already.`;
528      delete next[name];
529    } else for (const k of Object.keys(next)) delete next[k];
530
531    await $.fs.write(overridesFile(home, base), `${JSON.stringify(next, null, 2)}\n`);
532    const after = applyOverrides(base, next);
533    const seen = await observe($, after);
534    const lines: string[] = [];
535    for (const s of after.services) {
536      const was = stack.services.find((x) => x.name === s.name)!;
537      if (was.dir === s.dir) continue;
538      await stop($, stack, was);
539      const waiting = s.after.filter((d) => seen[d]?.state !== "up");
540      if (waiting.length) {
541        lines.push(formatStep({ prefix: "SKIP", text: `${s.name}: now from ${s.dir}; waits for ${waiting.join(", ")}, so the next /dev-up starts it` }));
542        continue;
543      }
544      const r = await start($, after, s);
545      lines.push(
546        r.exitCode === 0
547          ? formatStep({ prefix: "STARTED", text: `${s.name}: from ${s.dir}  (log ${logOf(home, after, s)})` })
548          : formatStep({ prefix: "WARN", text: `${s.name}: stopped, but it did not start from ${s.dir}: ${lastLine(r.stderr)}` }),
549      );
550      seen[s.name] = { state: "starting" };
551    }
552    if (!lines.length) lines.push(verb === "use" ? "Already served from there." : "Nothing was moved.");
553    await show($, after, seen);
554    return [...lines, "", statusLine(after, seen)].join("\n");
555  }
556  if (verb === "restart") {
557    const s = serviceOf(stack, name);
558    if (typeof s === "string") return s;
559    if (s.kind === "compose") {
560      if (rest) return `${s.name} is containers; restart takes no extra arguments for it.`;
561      const r = await run($, ["docker", "compose", "up", "-d", "--force-recreate"], { cwd: s.dir, timeoutMs: 300_000 });
562      return r.exitCode === 0 ? `Recreated ${s.name}'s containers. Run /dev-up once they are healthy.` : `Recreate failed: ${lastLine(r.stderr)}`;
563    }
564    if (s.kind === "check") {
565      if (rest) return `${s.name} is a check script; restart takes no extra arguments for it.`;
566      return (await runCheck($, stack, s, false)).lines.join("\n");
567    }
568    const seen = await observe($, stack);
569    const waiting = s.after.filter((d) => seen[d]?.state !== "up");
570    if (waiting.length) return `${s.name} waits for ${waiting.join(", ")}, which is not up. Run /dev-up first.`;
571    await stop($, stack, s);
572    const r = await start($, stack, s, rest);
573    return r.exitCode === 0
574      ? `Restarted ${s.name}${rest ? ` with ${rest}` : ""}  (log ${logOf(home, stack, s)})`
575      : `Stopped ${s.name}, but it did not start: ${lastLine(r.stderr)}`;
576  }
577  return `Unknown: ${verb}\n\n${HELP}`;
578}
579
580/* ---- /dev-up panel ---- */
581
582const PANE = "dev-up";
583const PANEL_MS = 3_000;
584const PANEL_LINES = 40;
585const panel = atom({ plugin: "dev-up", key: "panel" } as const, null);
586let panelTimer: { cancel: () => void } | undefined;
587let filling = false;
588
589/** A log line as text: colors, cursor moves and carriage returns taken out. */
590const plain = (line: string) =>
591  line
592    .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
593    .replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "")
594    .replace(/\r/g, "");
595
596async function logLines($: EngineInterface, home: string, stack: Stack, s: Service): Promise<string[]> {
597  const n = String(PANEL_LINES);
598  if (s.kind === "check") {
599    try {
600      const saved = JSON.parse(await $.fs.read(checkFile(home, stack, s))) as { lines?: string[] };
601      return saved.lines?.length ? saved.lines : ["(no output yet)"];
602    } catch {
603      return ["(not run yet: /dev-up runs it)"];
604    }
605  }
606  const r =
607    s.kind === "compose"
608      ? await run($, ["docker", "compose", "logs", "--no-color", "--tail", n], { cwd: s.dir, timeoutMs: 10_000 })
609      : await run($, ["tail", "-n", n, logOf(home, stack, s)], { timeoutMs: 5_000 });
610  if (r.exitCode !== 0) return [s.kind === "compose" ? "(docker compose logs failed)" : "(no log: not started by /dev-up)"];
611  const lines = r.stdout.split("\n").map(plain).filter((l) => l.trim());
612  return lines.length ? lines : ["(empty)"];
613}
614
615const detailOf = (s: Service) => (s.kind === "server" ? (s.port ? `:${s.port}` : "server") : s.kind === "compose" ? "docker" : s.kind);
616
617/** Refills the panel's cells; written only when something changed. */
618async function fillPanel($: EngineInterface, folderSetting: string) {
619  if (filling) return;
620  filling = true;
621  try {
622    const { stack } = await findStack($, folderSetting);
623    let next: Panel = { stack: "", cells: [] };
624    if (stack) {
625      const home = await homeOf($);
626      const seen = await observe($, stack);
627      const cells: Cell[] = await Promise.all(
628        stack.services.map(async (s) => {
629          const state = seen[s.name]?.state ?? "down";
630          return {
631            name: s.name,
632            kind: s.kind,
633            state,
634            ...(s.kind === "task" && state === "up" ? { done: true as const } : {}),
635            ...(s.servedFrom ? { from: s.servedFrom } : {}),
636            detail: detailOf(s),
637            lines: await logLines($, home, stack, s),
638          };
639        }),
640      );
641      next = { stack: stack.name, cells };
642      await show($, stack, seen);
643    }
644    if (JSON.stringify(await read($, panel)) !== JSON.stringify(next)) await update($, panel, () => next);
645  } catch {
646    // The next tick tries again.
647  } finally {
648    filling = false;
649  }
650}
651
652async function openPanel($: EngineInterface, folderSetting: string) {
653  await $.ui.open({ id: PANE, title: "Dev stack" });
654  await fillPanel($, folderSetting);
655  panelTimer ??= $.clock.every(PANEL_MS, () => {
656    void (async () => {
657      if (!(await $.ui.panes()).some((p) => p.id === PANE)) {
658        panelTimer?.cancel();
659        panelTimer = undefined;
660        return;
661      }
662      await fillPanel($, folderSetting);
663    })();
664  });
665}
666
667let refreshing = false;
668
669async function refresh($: EngineInterface, folderSetting: string) {
670  if (refreshing) return;
671  refreshing = true;
672  try {
673    const { stack } = await findStack($, folderSetting);
674    await show($, stack, stack ? await observe($, stack) : {});
675  } catch {
676    // A refresh in the background has nobody to tell; the next one, or a command, tries again.
677  } finally {
678    refreshing = false;
679  }
680}
681
682export const register: Register = (on, options) => {
683  const folderSetting = String(options.stacks ?? "~/.claude/dev-stacks");
684
685  /** Set when another /dev-up (a skill, a command file) holds the name: that one answers it. */
686  let commandTaken = false;
687
688  on("session.start", async ($, e, next) => {
689    // Versions before the dots pinned a plain status line; a pinned line outlives a reload.
690    $.ui.status(undefined);
691    try {
692      await $.command.register({
693        name: "dev-up",
694        description: "Bring up this folder's dev stack: start what is down, skip what waits",
695        argumentHint: "[panel|status|restart <svc> [args]|stop [<svc>]|logs <svc>|use <dir>|restore|--dry-run]",
696      });
697    } catch (error) {
698      commandTaken = true;
699      $.ui.log(`dev-up: /dev-up is taken (${error instanceof Error ? error.message : String(error)}); the dev_up tool and the dots under the prompt still work`);
700    }
701    await $.tool.register({
702      name: TOOL,
703      description:
704        "The dev stack for the session's folder, from ~/.claude/dev-stacks/<name>.yml. " +
705        "action=up runs one pass and returns at once: it starts what is down and skips what waits on something still starting. " +
706        "Call up again later to finish a pass that skipped things; never start these servers from Bash, where they die with the turn. " +
707        "status reports each service; restart and stop take a service; logs prints its last lines. " +
708        "use serves the servers of a worktree's repo from that worktree (dir), for every session; restore puts them back.",
709      inputSchema: {
710        type: "object",
711        properties: {
712          action: { type: "string", enum: ["up", "status", "restart", "stop", "logs", "dry-run", "use", "restore"] },
713          service: { type: "string", description: "The service, for restart, stop and logs." },
714          lines: { type: "number", description: "For logs: how many lines (40)." },
715          dir: { type: "string", description: "For use: the worktree to serve its repo's servers from." },
716          args: { type: "string", description: "For restart: added to the service's command for this start only, e.g. \"-- --clear\"." },
717        },
718        required: ["action"],
719      },
720    });
721    void refresh($, folderSetting);
722    $.clock.every(REFRESH_MS, () => void refresh($, folderSetting));
723    return next(e);
724  });
725
726  on("ui.close", async ($, e, next) => {
727    if (e.id === PANE) {
728      panelTimer?.cancel();
729      panelTimer = undefined;
730    }
731    return next(e);
732  });
733
734  // The panel: one bordered cell per service, its state on the frame, the end of its log inside.
735  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
736    const { Box, Text, Button } = $.ui.resolve(e);
737    const p = await read($, panel);
738    if (!p) return <Text dimColor>Looking at the stack…</Text>;
739    if (!p.cells.length) return <Text dimColor>No stack covers this folder. A stack is ~/.claude/dev-stacks/name.yml.</Text>;
740    const width = Math.max(20, e.props.bodyColumns);
741    const cols = width >= 150 ? 3 : width >= 90 ? 2 : 1;
742    const cellWidth = Math.floor((width - (cols - 1)) / cols);
743    const gridRows = Math.ceil(p.cells.length / cols);
744    const logRows = Math.max(3, Math.min(30, Math.floor(e.props.scroll.bodyRows / gridRows) - 3));
745    return (
746      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
747        {p.cells.map((c) => (
748          <Box
749            key={`cell-${c.name}`}
750            width={cellWidth}
751            height={logRows + 3}
752            flexDirection="column"
753            borderStyle="round"
754            {...(c.state === "down" ? { borderDimColor: true } : { borderColor: COLOR[c.state] })}
755            overflow="hidden"
756          >
757            <Box flexDirection="row" gap={1}>
758              {c.state === "down" ? (
759                <Text dimColor>{GLYPH.down}</Text>
760              ) : (
761                <Text color={COLOR[c.state]}>{c.done ? "✓" : GLYPH[c.state]}</Text>
762              )}
763              <Text bold>
764                {c.name}
765                {c.from ? `@${c.from}` : ""}
766              </Text>
767              <Text dimColor>{c.detail}</Text>
768              {(c.kind === "server" || c.kind === "task") && (
769                <Button
770                  key={`restart-${c.name}`}
771                  label="restart"
772                  plain
773                  dimColor
774                  onPress={() =>
775                    void (async () => {
776                      const out = await dispatch($, folderSetting, `restart ${c.name}`);
777                      $.ui.toast(out.split("\n")[0] ?? out);
778                      await fillPanel($, folderSetting);
779                    })()
780                  }
781                />
782              )}
783            </Box>
784            {c.lines.slice(-logRows).map((line, i) => (
785              <Text key={`log-${c.name}-${i}`} dimColor wrap="truncate-end">
786                {line}
787              </Text>
788            ))}
789          </Box>
790        ))}
791      </Box>
792    );
793  });
794
795  // The stack's dots, colored, at the end of the hint line under the prompt.
796  on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
797    const d = await read($, dots);
798    if (!d) return next(e);
799    const own = await next(e);
800    const { Box, Text } = $.ui.resolve(e);
801    return (
802      <Box flexDirection="row" gap={2}>
803        {own}
804        <Box key="dev-up" flexDirection="row" gap={1}>
805          <Text dimColor>dev</Text>
806          {d.services.map((s) => (
807            <Box key={`dev-up-${s.name}`} flexDirection="row">
808              {s.state === "down" ? (
809                <Text dimColor>{GLYPH.down}</Text>
810              ) : (
811                <Text color={COLOR[s.state]}>{s.done ? "✓" : GLYPH[s.state]}</Text>
812              )}
813              <Text dimColor>
814                {" "}
815                {s.name}
816                {s.from ? `@${s.from}` : ""}
817              </Text>
818            </Box>
819          ))}
820        </Box>
821      </Box>
822    );
823  });
824
825  on("command.run", { command: "dev-up" }, async ($, e, next) =>
826    commandTaken ? next(e) : { text: await dispatch($, folderSetting, e.args) },
827  );
828
829  on("tool.call", { tool: "mcp__dev-up__dev_up" }, async ($, e) => {
830    const input = e as unknown as { action?: string; service?: string; lines?: number; args?: string; dir?: string };
831    const extra = input.action === "restart" ? (input.args ?? "") : input.lines ? String(input.lines) : "";
832    const target = input.action === "use" ? (input.dir ?? input.service ?? "") : (input.service ?? "");
833    const args = [input.action ?? "up", target, extra].join(" ");
834    return { result: await dispatch($, folderSetting, args) };
835  });
836};
837
hooks/stack.ts 360 lines
1/**
2 * A stack file, and what one pass of /dev-up does with it. Everything here is
3 * pure: the hooks module observes the machine, asks `plan` what to do, and
4 * does it. Nothing in this file names a project.
5 */
6import { parseYaml } from "./yaml";
7
8export type Kind = "compose" | "server" | "task" | "check";
9
10export type Service = {
11  name: string;
12  kind: Kind;
13  /** Services that must be up before this one starts. */
14  after: string[];
15  /** Folder the command runs in, absolute (from `cwd` or `compose`). */
16  dir: string;
17  /** compose: the containers that must report healthy, and that must be running. */
18  healthy: string[];
19  running: string[];
20  /** server: the command and the port that says it is up. */
21  run?: string;
22  port?: number;
23  /** task: the command and the path whose presence says it is done. */
24  task?: string;
25  creates?: string;
26  /** check: the script, absolute, and an optional read-only command whose exit 0 says it is up. */
27  check?: string;
28  probe?: string;
29  /** Set when `/dev-up use` moved this service onto another checkout: that checkout's folder name. */
30  servedFrom?: string;
31};
32
33export type Stack = {
34  name: string;
35  /** The stack file, and the folder that holds it. */
36  file: string;
37  /** The folder the stack covers; a session under it uses this stack. */
38  root: string;
39  notes?: string;
40  services: Service[];
41  report: { name: string; url: string }[];
42};
43
44export class StackError extends Error {}
45
46const NAME = /^[A-Za-z0-9_.-]+$/;
47
48const dirOf = (path: string) => path.replace(/\/[^/]*$/, "") || "/";
49
50/** `~` to home, then relative to `base`; no trailing slash. */
51export function resolvePath(path: string, base: string, home: string): string {
52  const expanded = path.replace(/^~(?=$|\/)/, home);
53  const absolute = expanded.startsWith("/") ? expanded : `${base}/${expanded}`;
54  const parts: string[] = [];
55  for (const part of absolute.split("/")) {
56    if (part === "" || part === ".") continue;
57    if (part === "..") parts.pop();
58    else parts.push(part);
59  }
60  return `/${parts.join("/")}`;
61}
62
63const list = (value: unknown, what: string): string[] => {
64  if (value == null) return [];
65  if (typeof value === "string") return [value];
66  if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
67  throw new StackError(`${what} must be a name or a list of names`);
68};
69
70const text = (value: unknown, what: string): string | undefined => {
71  if (value == null) return undefined;
72  if (typeof value === "string" || typeof value === "number") return String(value);
73  throw new StackError(`${what} must be text`);
74};
75
76export function parseStack(source: string, file: string, home: string): Stack {
77  let doc: unknown;
78  try {
79    doc = parseYaml(source);
80  } catch (error) {
81    throw new StackError(error instanceof Error ? error.message : String(error));
82  }
83  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new StackError("the file is not a map");
84  const d = doc as Record<string, unknown>;
85
86  const name = text(d.name, "name") ?? file.split("/").at(-1)!.replace(/\.ya?ml$/, "");
87  if (!NAME.test(name)) throw new StackError(`name: "${name}" must be letters, digits, . _ - (it names a folder)`);
88  const here = dirOf(file);
89  const rootText = text(d.root, "root");
90  if (!rootText) throw new StackError("root: is missing (the folder this stack covers)");
91  const root = resolvePath(rootText, here, home);
92
93  if (!d.services || typeof d.services !== "object" || Array.isArray(d.services))
94    throw new StackError("services: is missing, or not a map");
95  const given = d.services as Record<string, unknown>;
96
97  const services = new Map<string, Service>();
98  for (const [key, raw] of Object.entries(given)) {
99    if (!NAME.test(key)) throw new StackError(`"${key}": a service name is letters, digits, . _ -`);
100    const s = (raw ?? {}) as Record<string, unknown>;
101    if (typeof s !== "object" || Array.isArray(s)) throw new StackError(`${key}: must be a map`);
102    const kinds = (["compose", "run", "task", "check"] as const).filter((k) => s[k] != null);
103    if (kinds.length !== 1)
104      throw new StackError(`${key}: needs exactly one of compose, run, task or check (found ${kinds.join(", ") || "none"})`);
105    const kind: Kind = kinds[0] === "run" ? "server" : kinds[0]!;
106    const cwd = text(s.cwd, `${key}.cwd`);
107    const port = s.port == null ? undefined : Number(s.port);
108    if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
109      throw new StackError(`${key}.port must be a port number`);
110    const ready = (s.ready ?? {}) as Record<string, unknown>;
111    if (typeof ready !== "object" || Array.isArray(ready)) throw new StackError(`${key}.ready must be a map`);
112    const service: Service = {
113      name: key,
114      kind,
115      after: list(s.after, `${key}.after`),
116      dir: resolvePath(kind === "compose" ? text(s.compose, `${key}.compose`)! : (cwd ?? "."), root, home),
117      healthy: list(ready.healthy, `${key}.ready.healthy`),
118      running: list(ready.up, `${key}.ready.up`),
119    };
120    if (kind === "server") {
121      service.run = text(s.run, `${key}.run`);
122      service.port = port;
123    }
124    if (kind === "task") {
125      service.task = text(s.task, `${key}.task`);
126      const creates = text(s.creates, `${key}.creates`);
127      if (!creates) throw new StackError(`${key}: a task needs creates: (the path that says it is done)`);
128      service.creates = resolvePath(creates, service.dir, home);
129    }
130    if (kind === "check") {
131      service.check = resolvePath(text(s.check, `${key}.check`)!, here, home);
132      service.probe = text(s.probe, `${key}.probe`);
133    } else if (s.probe != null) throw new StackError(`${key}: probe: is for a check service`);
134    services.set(key, service);
135  }
136  if (services.size === 0) throw new StackError("services: lists nothing");
137
138  for (const s of services.values())
139    for (const dep of s.after)
140      if (!services.has(dep)) throw new StackError(`${s.name} waits for "${dep}", which is not a service`);
141
142  // Dependencies first, file order otherwise.
143  const ordered: Service[] = [];
144  const done = new Set<string>();
145  const visiting = new Set<string>();
146  const visit = (s: Service) => {
147    if (done.has(s.name)) return;
148    if (visiting.has(s.name)) throw new StackError(`${s.name} waits for itself, through after:`);
149    visiting.add(s.name);
150    for (const dep of s.after) visit(services.get(dep)!);
151    visiting.delete(s.name);
152    done.add(s.name);
153    ordered.push(s);
154  };
155  for (const s of services.values()) visit(s);
156
157  const reportRaw = d.report ?? {};
158  if (typeof reportRaw !== "object" || Array.isArray(reportRaw)) throw new StackError("report: must be a map of name: url");
159  const report = Object.entries(reportRaw as Record<string, unknown>).map(([k, v]) => ({
160    name: k,
161    url: text(v, `report.${k}`) ?? "",
162  }));
163
164  const notes = text(d.notes, "notes");
165  return { name, file, root, notes: notes && resolvePath(notes, here, home), services: ordered, report };
166}
167
168/** The stack whose root holds `cwd`, the deepest root winning. */
169export function pickStack(stacks: Stack[], cwd: string): Stack | undefined {
170  return stacks
171    .filter((s) => cwd === s.root || cwd.startsWith(`${s.root}/`) || s.root === "/")
172    .sort((a, b) => b.root.length - a.root.length)[0];
173}
174
175export type State = "down" | "starting" | "up" | "broken";
176/**
177 * `needsInstall` says why the folder needs an install before it can start;
178 * `recreate`, which compose containers lost a bind mount and need recreating.
179 */
180export type Seen = { state: State; why?: string; needsInstall?: string; recreate?: string[] };
181export type Observed = Record<string, Seen>;
182
183export type Prefix = "UP" | "STARTED" | "SKIP" | "WARN" | "STOP" | "ACTION";
184export type Action = "compose" | "recreate" | "start" | "check";
185/** `command` is what a compose or recreate step runs, in the service's folder. */
186export type Step = { service: string; prefix: Prefix; text: string; action?: Action; command?: string[] };
187
188/**
189 * One pass: report what is up, start what is down, skip what waits for
190 * something not up yet. A service this pass starts does not count as up for
191 * the ones after it: the next /dev-up starts them. Nothing waits.
192 */
193export function plan(stack: Stack, seen: Observed): Step[] {
194  const steps: Step[] = [];
195  const isUp = (name: string) => seen[name]?.state === "up";
196  for (const s of stack.services) {
197    const { state, why, needsInstall, recreate } = seen[s.name] ?? { state: "down" as const };
198    const say = (prefix: Prefix, text: string, action?: Action, command?: string[]) =>
199      steps.push({ service: s.name, prefix, text: `${s.name}: ${text}`, ...(action ? { action } : {}), ...(command ? { command } : {}) });
200
201    if (s.kind === "check") {
202      // The script is its own health check: it runs on every pass once what it waits for is up.
203      const waiting = s.after.filter((d) => !isUp(d));
204      if (waiting.length) say("SKIP", `waits for ${waiting.join(", ")}; next run`);
205      else say("STARTED", s.check!, "check");
206      continue;
207    }
208    if (s.kind === "compose") {
209      if (recreate?.length) {
210        // --no-deps: recreating a healthy dependency would only make it start over.
211        const command = ["docker", "compose", "up", "-d", "--no-deps", "--force-recreate", ...recreate];
212        say("STARTED", `${command.join(" ")}  (${why})`, "recreate", command);
213      } else if (state === "up") say("UP", why ?? "containers up");
214      else if (state === "starting") say("SKIP", `${why ?? "not healthy yet"}; what waits for it starts next run`);
215      else if (state === "broken") say("WARN", why ?? "a container needs a hand");
216      else say("STARTED", "docker compose up -d", "compose", ["docker", "compose", "up", "-d"]);
217      continue;
218    }
219    if (state === "up") {
220      say("UP", s.kind === "server" ? (s.port ? `:${s.port}` : "running") : "done");
221      continue;
222    }
223    if (state === "starting") {
224      say("SKIP", s.kind === "server" && s.port ? `started, not answering on :${s.port} yet` : "still running; next run");
225      continue;
226    }
227    if (state === "broken") say("WARN", why ?? "exited since it was started");
228    if (needsInstall) {
229      say("WARN", `${needsInstall}: install there first`);
230      continue;
231    }
232    const waiting = s.after.filter((d) => !isUp(d));
233    if (waiting.length) {
234      say("SKIP", `waits for ${waiting.join(", ")}; next run`);
235      continue;
236    }
237    say("STARTED", s.kind === "server" ? s.run! : s.task!, "start");
238  }
239  return steps;
240}
241
242/** Which checkout serves a service instead of its own: service name → folder, absolute. */
243export type Overrides = Record<string, string>;
244
245/** `path` moved from one checkout's top folder to another's, or undefined when it is outside the first. */
246export function rebase(path: string, fromTop: string, toTop: string): string | undefined {
247  if (path === fromTop) return toTop;
248  return path.startsWith(`${fromTop}/`) ? toTop + path.slice(fromTop.length) : undefined;
249}
250
251/** The other checkout's folder name: `dir` with the trailing folders it shares with `own` taken off. */
252function checkoutName(own: string, dir: string): string {
253  const a = own.split("/").filter(Boolean);
254  const b = dir.split("/").filter(Boolean);
255  while (a.length > 1 && b.length > 1 && a.at(-1) === b.at(-1)) a.pop(), b.pop();
256  return b.at(-1) ?? dir;
257}
258
259/** The stack with each overridden server or task run from its other checkout. */
260export function applyOverrides(stack: Stack, overrides: Overrides): Stack {
261  return {
262    ...stack,
263    services: stack.services.map((s) => {
264      const dir = overrides[s.name];
265      if (!dir || dir === s.dir || (s.kind !== "server" && s.kind !== "task")) return s;
266      return {
267        ...s,
268        dir,
269        servedFrom: checkoutName(s.dir, dir),
270        ...(s.creates ? { creates: rebase(s.creates, s.dir, dir) ?? s.creates } : {}),
271      };
272    }),
273  };
274}
275
276const GLYPH: Record<State, string> = { up: "●", starting: "◐", down: "○", broken: "✕" };
277
278export function statusLine(stack: Stack, seen: Observed): string {
279  const parts = stack.services.map((s) => {
280    const state = seen[s.name]?.state ?? "down";
281    const glyph = s.kind === "task" && state === "up" ? "✓" : GLYPH[state];
282    return `${glyph} ${s.name}${s.servedFrom ? `@${s.servedFrom}` : ""}`;
283  });
284  return `${stack.name}  ${parts.join("  ")}`;
285}
286
287export const formatStep = (step: Pick<Step, "prefix" | "text">) => `${step.prefix.padEnd(8)}${step.text}`;
288
289/** A check script's line, `PREFIX text`, or undefined for any other line. */
290export function parseLine(line: string): { prefix: Prefix; text: string } | undefined {
291  const m = /^(UP|STARTED|SKIP|WARN|STOP|ACTION)\s+(.*)$/.exec(line.trim());
292  return m ? { prefix: m[1] as Prefix, text: m[2]! } : undefined;
293}
294
295/** A check's state from its lines: the worst one wins. */
296export function stateOfLines(prefixes: Prefix[]): State {
297  if (prefixes.some((p) => p === "WARN" || p === "STOP")) return "broken";
298  if (prefixes.some((p) => p === "STARTED" || p === "SKIP" || p === "ACTION")) return "starting";
299  return prefixes.length ? "up" : "down";
300}
301
302type PsRow = { ID?: string; Service?: string; State?: string; Health?: string; Status?: string };
303
304/** The rows of `docker compose ps -a --format json`: one JSON array, or one object per line. */
305export function composeRows(psJson: string): PsRow[] {
306  const trimmed = psJson.trim();
307  if (trimmed.startsWith("[")) return JSON.parse(trimmed);
308  return trimmed
309    .split("\n")
310    .filter((line) => line.trim())
311    .map((line) => JSON.parse(line));
312}
313
314/** The containers that exited 127: Docker could not start them, or their command was not found. */
315export const exited127 = (rows: PsRow[]) => rows.filter((r) => /Exited \(127\)/.test(r.Status ?? ""));
316
317/**
318 * Docker Desktop on WSL drops its bind-mount handles when it restarts. A
319 * container that had one (a bind mount, a compose `configs:` file) then fails
320 * to start with this, and only recreating it gives it a new handle.
321 */
322const LOST_MOUNT = /failed to fulfil mount request|docker-desktop-bind-mounts/;
323
324/**
325 * A compose project's state from `docker compose ps -a --format json`.
326 * `errors` is each 127 container's `.State.Error` from `docker inspect`, by
327 * service: the error says whether a lost bind mount is why it exited.
328 */
329export function composeState(s: Service, psJson: string, errors: Record<string, string> = {}): Seen {
330  const rows = composeRows(psJson);
331  if (rows.length === 0) return { state: "down", why: "no containers" };
332
333  const dead = exited127(rows);
334  const lost = dead.filter((r) => LOST_MOUNT.test(errors[r.Service ?? ""] ?? "")).map((r) => r.Service ?? "");
335  const other = dead.filter((r) => !lost.includes(r.Service ?? ""));
336  if (other.length) {
337    const name = other[0]!.Service;
338    return {
339      state: "broken",
340      why: errors[name ?? ""]
341        ? `${name} is Exited (127): ${errors[name ?? ""]}`
342        : `${name} is Exited (127), maybe a stale bind mount after Docker restarted: docker compose up -d --force-recreate ${name}`,
343    };
344  }
345  if (lost.length) return { state: "down", why: `${lost.join(", ")} lost a bind mount when Docker restarted`, recreate: lost };
346
347  const byName = new Map(rows.map((r) => [r.Service ?? "", r]));
348  const want = s.healthy.length || s.running.length ? [...s.healthy, ...s.running] : [...byName.keys()];
349  const stopped = want.filter((n) => byName.get(n)?.State !== "running");
350  if (stopped.length) return { state: "down", why: `${stopped.join(", ")} not running` };
351  const unhealthy = s.healthy.filter((n) => byName.get(n)?.Health !== "healthy");
352  if (unhealthy.length) {
353    const sick = unhealthy.filter((n) => byName.get(n)?.Health === "unhealthy");
354    if (sick.length) return { state: "broken", why: `${sick.join(", ")} unhealthy: check docker compose logs` };
355    return { state: "starting", why: `${unhealthy.join(", ")} not healthy yet` };
356  }
357  const parts = [...s.healthy.map((n) => `${n} healthy`), ...s.running.map((n) => `${n} up`)];
358  return { state: "up", why: parts.length ? parts.join(", ") : `${want.length} containers up` };
359}
360
hooks/yaml.ts 194 lines
1/**
2 * The YAML a stack file needs, and no more: block maps and lists by
3 * indentation, `[a, b]` and `{ k: v }` on one line, quoted and plain scalars,
4 * numbers, true/false/null and `#` comments. No anchors, tags, multi-line
5 * strings or documents. A mod has no npm packages, so this stands in for one.
6 */
7
8export class YamlError extends Error {
9  constructor(message: string, readonly line: number) {
10    super(`line ${line}: ${message}`);
11  }
12}
13
14type Line = { n: number; indent: number; text: string };
15
16export function parseYaml(source: string): unknown {
17  const lines: Line[] = [];
18  source.split(/\r?\n/).forEach((raw, i) => {
19    const text = stripComment(raw).replace(/\s+$/, "");
20    if (!text.trim()) return;
21    if (/^ *\t/.test(text)) throw new YamlError("indent with spaces, not tabs", i + 1);
22    lines.push({ n: i + 1, indent: text.length - text.trimStart().length, text: text.trim() });
23  });
24  if (lines.length === 0) return null;
25  const [value, next] = parseBlock(lines, 0, lines[0]!.indent);
26  if (next < lines.length) throw new YamlError("unexpected indentation", lines[next]!.n);
27  return value;
28}
29
30const isItem = (text: string) => text === "-" || text.startsWith("- ");
31
32function parseBlock(lines: Line[], i: number, indent: number): [unknown, number] {
33  return isItem(lines[i]!.text) ? parseList(lines, i, indent) : parseMap(lines, i, indent);
34}
35
36const KEY = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^:"'[\]{}#,][^:]*?)\s*:(?:\s+(.*))?$/;
37
38function parseMap(lines: Line[], i: number, indent: number): [Record<string, unknown>, number] {
39  const out: Record<string, unknown> = {};
40  while (i < lines.length && lines[i]!.indent === indent) {
41    const line = lines[i]!;
42    if (isItem(line.text)) throw new YamlError("a list item where a key was expected", line.n);
43    const m = KEY.exec(line.text);
44    if (!m) throw new YamlError(`expected "key: value", found "${line.text}"`, line.n);
45    const key = unquote(m[1]!, line.n);
46    if (Object.hasOwn(out, key)) throw new YamlError(`"${key}" is given twice`, line.n);
47    const rest = m[2];
48    i++;
49    const below = lines[i];
50    if (rest) out[key] = parseInline(rest, line.n);
51    else if (below && below.indent > indent) [out[key], i] = parseBlock(lines, i, below.indent);
52    else if (below && below.indent === indent && isItem(below.text)) [out[key], i] = parseList(lines, i, indent);
53    else out[key] = null;
54  }
55  if (i < lines.length && lines[i]!.indent > indent) throw new YamlError("unexpected indentation", lines[i]!.n);
56  return [out, i];
57}
58
59function parseList(lines: Line[], i: number, indent: number): [unknown[], number] {
60  const out: unknown[] = [];
61  while (i < lines.length && lines[i]!.indent === indent && isItem(lines[i]!.text)) {
62    const line = lines[i]!;
63    const rest = line.text.slice(1).trimStart();
64    if (!rest) {
65      i++;
66      const below = lines[i];
67      if (below && below.indent > indent) {
68        let value: unknown;
69        [value, i] = parseBlock(lines, i, below.indent);
70        out.push(value);
71      } else out.push(null);
72    } else if (KEY.test(rest)) {
73      // `- key: value` opens a map whose keys line up with `key`.
74      const column = indent + line.text.length - rest.length;
75      lines[i] = { n: line.n, indent: column, text: rest };
76      let value: unknown;
77      [value, i] = parseMap(lines, i, column);
78      out.push(value);
79    } else {
80      out.push(parseInline(rest, line.n));
81      i++;
82    }
83  }
84  return [out, i];
85}
86
87type Cursor = { s: string; i: number; n: number };
88
89function parseInline(text: string, n: number): unknown {
90  const p: Cursor = { s: text, i: 0, n };
91  const value = flowValue(p, false);
92  skip(p);
93  if (p.i < p.s.length) throw new YamlError(`unexpected "${p.s.slice(p.i)}"`, n);
94  return value;
95}
96
97function skip(p: Cursor) {
98  while (p.s[p.i] === " ") p.i++;
99}
100
101function flowValue(p: Cursor, inFlow: boolean): unknown {
102  skip(p);
103  const c = p.s[p.i];
104  if (c === "[") {
105    p.i++;
106    const list: unknown[] = [];
107    skip(p);
108    if (p.s[p.i] === "]") return p.i++, list;
109    for (;;) {
110      list.push(flowValue(p, true));
111      skip(p);
112      const d = p.s[p.i++];
113      if (d === "]") return list;
114      if (d !== ",") throw new YamlError('expected "," or "]"', p.n);
115    }
116  }
117  if (c === "{") {
118    p.i++;
119    const map: Record<string, unknown> = {};
120    skip(p);
121    if (p.s[p.i] === "}") return p.i++, map;
122    for (;;) {
123      skip(p);
124      const key = flowKey(p);
125      skip(p);
126      if (p.s[p.i++] !== ":") throw new YamlError(`expected ":" after "${key}"`, p.n);
127      map[key] = flowValue(p, true);
128      skip(p);
129      const d = p.s[p.i++];
130      if (d === "}") return map;
131      if (d !== ",") throw new YamlError('expected "," or "}"', p.n);
132    }
133  }
134  if (c === '"' || c === "'") return quoted(p);
135  let j = p.i;
136  while (j < p.s.length && !(inFlow && /[,\]}]/.test(p.s[j]!))) j++;
137  const raw = p.s.slice(p.i, j).trim();
138  p.i = j;
139  return scalar(raw);
140}
141
142function flowKey(p: Cursor): string {
143  const c = p.s[p.i];
144  if (c === '"' || c === "'") return quoted(p);
145  const j = p.s.indexOf(":", p.i);
146  if (j < 0) throw new YamlError('expected "key: value"', p.n);
147  const key = p.s.slice(p.i, j).trim();
148  p.i = j;
149  return key;
150}
151
152function quoted(p: Cursor): string {
153  const q = p.s[p.i++];
154  let out = "";
155  while (p.i < p.s.length) {
156    const c = p.s[p.i++]!;
157    if (q === "'" && c === "'") {
158      if (p.s[p.i] === "'") (out += "'"), p.i++;
159      else return out;
160    } else if (q === '"' && c === '"') return out;
161    else if (q === '"' && c === "\\") {
162      const e = p.s[p.i++];
163      out += e === "n" ? "\n" : e === "t" ? "\t" : (e ?? "");
164    } else out += c;
165  }
166  throw new YamlError("a quote is not closed", p.n);
167}
168
169function unquote(text: string, n: number): string {
170  return /^["']/.test(text) ? parseInline(text, n) as string : text.trim();
171}
172
173function scalar(raw: string): unknown {
174  if (raw === "" || raw === "~" || raw === "null") return null;
175  if (raw === "true") return true;
176  if (raw === "false") return false;
177  if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
178  return raw;
179}
180
181/** Drops a `#` comment: at the start, or after a space, outside quotes. */
182function stripComment(raw: string): string {
183  let quote = "";
184  for (let i = 0; i < raw.length; i++) {
185    const c = raw[i]!;
186    if (quote) {
187      if (c === "\\" && quote === '"') i++;
188      else if (c === quote) quote = "";
189    } else if ((c === '"' || c === "'") && (i === 0 || /[\s:[{,]/.test(raw[i - 1]!))) quote = c;
190    else if (c === "#" && (i === 0 || /\s/.test(raw[i - 1]!))) return raw.slice(0, i);
191  }
192  return raw;
193}
194
types/index.d.ts 21 lines
1/** One service as the hint line draws it; `done` marks a task whose output exists (✓, not ●). */
2export type Dot = { name: string; state: "up" | "starting" | "down" | "broken"; done?: true; from?: string };
3/** The stack the session's folder belongs to, as last seen; null when none covers it. */
4export type Dots = { stack: string; services: Dot[] } | null;
5
6/** One cell of the panel: a service, its state, and the end of its log. */
7export type Cell = Dot & { kind: "compose" | "server" | "task" | "check"; detail: string; lines: string[] };
8/** What `/dev-up panel` draws; null until it first fills. */
9export type Panel = { stack: string; cells: Cell[] } | null;
10
11declare module "claude-code" {
12  interface PluginState {
13    "dev-up": {
14      /** What the hint line under the prompt shows, written by passes, commands and the 30 s refresh. */
15      dots: Dots;
16      /** The panel's cells, refilled every few seconds while it is open. */
17      panel: Panel;
18    };
19  }
20}
21