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…

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.
/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).
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.
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.
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.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).creates exists.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.
/dev-up | one pass |
/dev-up --dry-run | what a pass would do; check scripts get --dry-run |
/dev-up status | each service's state and why |
/dev-up panel | a 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 |
/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.
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.
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.
hooks/register.tsx 837 lines1import { 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};
837hooks/stack.ts 360 lines1/**
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}
360hooks/yaml.ts 194 lines1/**
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}
194types/index.d.ts 21 lines1/** 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