#!/usr/bin/env bun /** * sl0ppi entry point. * * FAIL OPEN. If anything here goes wrong — bad layout, unreadable root, * unexpected state, a bug — warn on stderr and hand over to plain sl0ppij with * the user's arguments. When your environment breaks, the terminal is the thing * you debug it with; an orchestrator must never stand between you and it. */ import { spawnSync } from "node:child_process"; import { UserError } from "./core/errors.ts"; import { formatCheck, gather, report, worstLevel } from "./commands/doctor.ts"; import { init, initMessage } from "./commands/init.ts"; import { close, ls, open, print, up, type Context } from "./commands/workspace.ts"; import { loadRegistry, resolveRoot } from "./core/root.ts"; import { DEFAULT_BIN, insideSession, Sl0ppij } from "./core/sl0ppij.ts"; const USAGE = `sl0ppi — orchestrates sl0ppij sessions (one root = one session, one project = one tab) usage: sl0ppi [up] create/attach the root session, one tab per project sl0ppi ls list projects and their tabs sl0ppi open ensure and focus a project's tab sl0ppi close close a project's tab sl0ppi init [dir] make a directory a project (writes .sl0ppi/layout.kdl) sl0ppi print show the layout sl0ppi would use for a project sl0ppi doctor check the installation sl0ppi help options: --root DIR treat DIR as the root (default: cwd, or the parent of a project) --depth N how deep to scan for .sl0ppi markers (default: 1) --force with init, overwrite an existing layout --fresh with up, discard a dead session instead of resurrecting it -n, --dry-run `; interface Args { command: string; rest: string[]; rootFlag?: string; depth: number; dryRun: boolean; force: boolean; fresh: boolean; } export function parseArgs(argv: string[]): Args { const rest: string[] = []; let rootFlag: string | undefined; let depth = 1; let dryRun = false; let force = false; let fresh = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i] as string; if (arg === "--root") rootFlag = argv[++i]; else if (arg.startsWith("--root=")) rootFlag = arg.slice("--root=".length); else if (arg === "--depth") depth = Number.parseInt(argv[++i] ?? "1", 10); else if (arg.startsWith("--depth=")) depth = Number.parseInt(arg.slice("--depth=".length), 10); else if (arg === "-n" || arg === "--dry-run") dryRun = true; else if (arg === "--force") force = true; else if (arg === "--fresh") fresh = true; else rest.push(arg); } const command = rest.length > 0 && KNOWN_VERBS.has(rest[0] as string) ? (rest.shift() as string) : "up"; return { command, rest, rootFlag, depth: Number.isFinite(depth) ? depth : 1, dryRun, force, fresh, }; } /** * Hand over to sl0ppij, replacing this process's role entirely. * * sl0ppi's own verbs and flags are stripped first: passing `up` to sl0ppij just * produces "unrecognized subcommand" and still leaves the user without a * terminal, which defeats the whole point of failing open. What survives is * anything sl0ppij would itself understand; if nothing does, a bare sl0ppij * gets you a session. */ const KNOWN_VERBS = new Set([ "up", "ls", "open", "close", "init", "print", "doctor", "help", ]); const OUR_VERBS = KNOWN_VERBS; const OUR_FLAGS = new Set(["--root", "--depth", "-n", "--dry-run", "--force", "--fresh"]); export function passthroughArgs(argv: string[]): string[] { const out: string[] = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i] as string; if (OUR_FLAGS.has(arg)) { if (arg === "--root" || arg === "--depth") i++; // also drop its value continue; } if (arg.startsWith("--root=") || arg.startsWith("--depth=")) continue; if (out.length === 0 && OUR_VERBS.has(arg)) continue; // only the leading verb out.push(arg); } return out; } function fallThrough(argv: string[], why?: string): never { if (why) process.stderr.write(`sl0ppi: ${why}\n`); const passthrough = passthroughArgs(argv); // Falling open means "you still get your terminal", which only makes sense at // a terminal. Without a tty, a bare sl0ppij would silently CREATE a session // (that is how a handful of randomly-named junk sessions appeared during // testing), so report and exit instead. if (passthrough.length === 0 && (!process.stdin.isTTY || !process.stdout.isTTY)) { process.stderr.write("sl0ppi: not a tty; not starting sl0ppij\n"); process.exit(1); } process.stderr.write("sl0ppi: falling through to sl0ppij\n"); const bin = process.env.SL0PPI_BIN ?? DEFAULT_BIN; const proc = spawnSync(bin, passthrough, { stdio: "inherit" }); process.exit(proc.status ?? 1); } export function main(argv: string[] = process.argv.slice(2)): void { let args: Args; try { args = parseArgs(argv); } catch (error) { fallThrough(argv, String(error)); } if (args.command === "help" || argv.includes("-h") || argv.includes("--help")) { process.stdout.write(USAGE); return; } const client = new Sl0ppij(); const sessionEnv = insideSession(); let ctx: Context; try { ctx = { resolved: resolveRoot({ rootFlag: args.rootFlag, cwd: process.cwd(), sessionEnv, registry: loadRegistry(), }), client, depth: args.depth, dryRun: args.dryRun, fresh: args.fresh, log: (message) => process.stdout.write(`${message}\n`), }; } catch (error) { fallThrough(argv, `could not resolve a root: ${describe(error)}`); } try { switch (args.command) { case "ls": { const { projects, stale } = ls(ctx); if (projects.length === 0) { process.stdout.write(`no projects under ${ctx.resolved.root}\n`); } for (const p of projects) { const state = p.tab ? `tab ${p.tab.position} ${p.panes} pane(s) agent:${p.agent}` : "—"; process.stdout.write(` ${p.relPath.padEnd(24)} ${state}\n`); } for (const tab of stale) { process.stdout.write(` ${tab.name.padEnd(24)} STALE (project gone)\n`); } return; } case "open": { const name = args.rest[0]; if (!name) throw new Error("open needs a project name"); open(ctx, name); return; } case "close": { const name = args.rest[0]; if (!name) throw new Error("close needs a project name"); close(ctx, name); return; } case "print": { const name = args.rest[0]; if (!name) throw new Error("print needs a project name"); process.stdout.write(print(ctx, name)); return; } case "init": { const dir = args.rest[0] ?? process.cwd(); process.stdout.write(`${initMessage(init(dir, { force: args.force }), dir)}\n`); return; } case "doctor": { const checks = report(gather({ client, depth: args.depth })); for (const check of checks) process.stdout.write(`${formatCheck(check)}\n`); const worst = worstLevel(checks); process.stdout.write( worst === "ok" ? "\nall good\n" : `\n${checks.filter((c) => c.level !== "ok").length} thing(s) to look at\n`, ); process.exitCode = worst === "fail" ? 1 : 0; return; } default: { // Attaching needs a terminal. Without this guard the client panics in // os_input_output when stdin is a pipe, which is what happens whenever // `sl0ppi up` runs from a script or a CI step. const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY); const willAttach = interactive && !args.dryRun && sessionEnv !== ctx.resolved.session; // When we are going to attach anyway, create the session BY attaching: // background creation leaves a phantom client behind for the life of the // session (see UpResult.createWith). const result = up({ ...ctx, deferCreate: willAttach }); // Inside the session already? Focusing beats nesting a second client. if (sessionEnv === ctx.resolved.session) { if (ctx.resolved.project) open(ctx, ctx.resolved.project); return; } if (args.dryRun) return; if (!willAttach) { process.stdout.write(`session ${ctx.resolved.session} ready (not a tty; not attaching)\n`); return; } const bin = process.env.SL0PPI_BIN ?? DEFAULT_BIN; const attachArgs = result.createWith ? ["--layout", result.createWith, "attach", "--create", ctx.resolved.session] : ["attach", ctx.resolved.session]; const proc = spawnSync(bin, attachArgs, { stdio: "inherit" }); process.exit(proc.status ?? 0); } } } catch (error) { // A UserError is an answer, not a malfunction: print it and stop. Handing // over to sl0ppij here would bury the explanation and start a session the // user never asked for. if (error instanceof UserError) { process.stderr.write(`sl0ppi: ${error.message}\n`); process.exit(1); } fallThrough(argv, describe(error)); } } function describe(error: unknown): string { return error instanceof Error ? error.message : String(error); } if (import.meta.main) main();