diff options
| author | user <user@clank> | 2026-08-12 22:24:55 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 22:24:55 +0200 |
| commit | 2cd247828d863cdc779c7178c988152835096ca2 (patch) | |
| tree | 867b63f58057e81ac8ab4d242e73b733b8372c9e /cli/src | |
| parent | docs(cli): record the fork gap the design turned up (diff) | |
| download | sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.tar.gz sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.tar.xz sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.zip | |
cli: up, ls, open, close
Slice 2 of docs/cli-design.md. The commands read the world (discovery +
list-tabs), compute a plan, then apply it -- never mutating while walking, which
is how `up` stays idempotent.
sl0ppij.ts process wrapper; every piece of state comes back out of
list-tabs/list-panes, so there is nothing to desync
layout.ts composes the tab node around a project's fragment
root.ts root resolution, session naming, the root registry
workspace.ts up / ls / open / close
index.ts dispatch + the fail-open wrapper
Four things this turned up that reading the code would not have:
- `attach --create-background` fails with "There is no active session!" when
the caller is itself inside a session -- which sl0ppi almost always is,
being run from the user's daily-driver terminal. Session creation now runs
with the SL0PPIJ*/ZELLIJ* markers stripped: we are creating a new detached
session, not nesting.
- falling open by passing our own argv to sl0ppij just produces "unrecognized
subcommand: up" and still leaves the user without a terminal. Our verbs and
flags are stripped first, so what is handed over is something sl0ppij
actually understands.
- attaching panics in the client when stdin is not a tty, so `up` from a
script or CI step died. It now reports the session is ready and exits.
- a start_suspended pane reports `is_held`, not `exited`. Conflating them made
`ls` claim every agent was running when none had started. Plugin panes (the
status bar, the link handler) also inflated pane counts and are now excluded
as chrome.
Index conventions were verified against a live session rather than inferred,
because guessing closes the wrong tab: list-tabs `position` and
`close-tab --tab-id` are 0-based, `go-to-tab` is 1-based, and closing renumbers
every tab after it, so batched closes run in descending position order.
Verified end to end against a real session (three projects, one shipping a
custom fragment): up creates the session with all tabs and their purposes; up
again reports "up to date" with tabs unchanged; ls reports panes and
agent:suspended correctly; open creates-and-focuses and resolves unique
prefixes; close removes a tab; deleting a project auto-closes its tab; and with
the root itself missing, nothing is closed at all.
64 tests green.
Diffstat (limited to 'cli/src')
| -rw-r--r-- | cli/src/commands/workspace.ts | 236 | ||||
| -rw-r--r-- | cli/src/core/layout.ts | 68 | ||||
| -rw-r--r-- | cli/src/core/root.ts | 116 | ||||
| -rw-r--r-- | cli/src/core/sl0ppij.ts | 205 | ||||
| -rw-r--r-- | cli/src/index.ts | 185 |
5 files changed, 810 insertions, 0 deletions
diff --git a/cli/src/commands/workspace.ts b/cli/src/commands/workspace.ts new file mode 100644 index 0000000..beaaf56 --- /dev/null +++ b/cli/src/commands/workspace.ts @@ -0,0 +1,236 @@ +/** + * up / ls / open / close. + * + * These are the plumbing on top of the planner. The rule they all obey: read + * the world (discovery + list-tabs), compute a plan, then apply it — never + * mutate opportunistically while walking, because that is how `up` stops being + * idempotent. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts"; +import { sessionLayoutKdl, singleTabLayoutKdl, type TabSpec } from "../core/layout.ts"; +import { isNoOp, planTabs, type Plan } from "../core/plan.ts"; +import { projectPurpose } from "../core/purpose.ts"; +import { rememberRoot, rootExists, type ResolvedRoot } from "../core/root.ts"; +import { Sl0ppij, type TabInfo } from "../core/sl0ppij.ts"; + +export interface Context { + resolved: ResolvedRoot; + client: Sl0ppij; + depth: number; + dryRun: boolean; + log: (message: string) => void; +} + +function fragmentFor(root: string, relPath: string): string | undefined { + const path = join(root, relPath, MARKER_DIR, LAYOUT_FILE); + try { + const text = readFileSync(path, "utf8").trim(); + return text.length > 0 ? text : undefined; + } catch { + return undefined; // no fragment: the default (one suspended agent) applies + } +} + +function specFor(root: string, relPath: string): TabSpec { + return { + name: basename(relPath), + relPath, + cwd: join(root, relPath), + fragment: fragmentFor(root, relPath), + }; +} + +function computePlan(ctx: Context, tabs: TabInfo[]): Plan { + const { root } = ctx.resolved; + return planTabs({ + root, + rootExists: rootExists(root), + projects: discoverProjects(root, { depth: ctx.depth }), + tabs, + pathExists: existsSync, + }); +} + +/** + * Create the session, or bring an existing one in line with the filesystem. + * + * Two paths, one composer: a brand new session is created from a whole-session + * layout (which is also the only way tab purposes survived before the fork fix), + * and an existing one gets tabs added individually. + */ +export function up(ctx: Context): { plan: Plan; created: boolean } { + const { root, session } = ctx.resolved; + const client = ctx.client.forSession(session); + + if (!rootExists(root)) { + throw new Error(`root does not exist: ${root}`); + } + + const projects = discoverProjects(root, { depth: ctx.depth }); + + if (!client.sessionExists(session)) { + const specs = projects.map((relPath) => specFor(root, relPath)); + if (ctx.dryRun) { + ctx.log(`would create session ${session} with ${specs.length} tab(s)`); + for (const spec of specs) ctx.log(` + ${spec.relPath}`); + return { plan: emptyPlan(), created: true }; + } + // A session must be born with at least one tab; an empty root gets a plain + // one rather than an error, so `sl0ppi` still gives you a terminal. + const layout = + specs.length > 0 + ? sessionLayoutKdl(specs) + : "layout {\n tab name=\"scratch\" {\n pane\n }\n}\n"; + const dir = mkdtempSync(join(tmpdir(), "sl0ppi-layout-")); + const layoutPath = join(dir, "session.kdl"); + writeFileSync(layoutPath, layout); + client.createBackground(session, layoutPath); + rememberRoot(session, root); + ctx.log(`created session ${session} (${specs.length} project tab(s))`); + return { plan: emptyPlan(), created: true }; + } + + const plan = computePlan(ctx, client.listTabs()); + + if (ctx.dryRun) { + if (isNoOp(plan)) ctx.log("nothing to do"); + for (const action of plan.create) ctx.log(` + ${action.relPath}`); + for (const tab of plan.close) ctx.log(` - ${tab.name} (stale)`); + return { plan, created: false }; + } + + // Closes first, in DESCENDING position order: closing a tab renumbers every + // tab after it, so ascending order would target the wrong ones. + for (const tab of [...plan.close].sort((a, b) => b.position - a.position)) { + client.closeTab(tab.position); + ctx.log(`- ${tab.name} (project gone)`); + } + + for (const action of plan.create) { + client.newTab(singleTabLayoutKdl(specFor(root, action.relPath))); + ctx.log(`+ ${action.relPath}`); + } + + rememberRoot(session, root); + if (isNoOp(plan)) ctx.log("up to date"); + return { plan, created: false }; +} + +export type AgentState = "running" | "suspended" | "exited" | "none"; + +export interface ProjectStatus { + relPath: string; + tab?: TabInfo; + /** Terminal panes only; plugin panes (status bar, link handler) are chrome. */ + panes: number; + agent: AgentState; +} + +/** + * `is_held` is how a `start_suspended` pane reports itself: the command is + * staged but has not run, waiting for a keypress. It is NOT `exited` — that + * means the command ran and finished. + */ +export function agentStateOf(pane: { is_held?: boolean; exited?: boolean } | undefined): AgentState { + if (pane === undefined) return "none"; + if (pane.is_held) return "suspended"; + if (pane.exited) return "exited"; + return "running"; +} + +export function ls(ctx: Context): { projects: ProjectStatus[]; stale: TabInfo[] } { + const { root, session } = ctx.resolved; + const client = ctx.client.forSession(session); + const projects = discoverProjects(root, { depth: ctx.depth }); + + if (!client.sessionExists(session)) { + return { + projects: projects.map((relPath) => ({ relPath, panes: 0, agent: "none" })), + stale: [], + }; + } + + const tabs = client.listTabs(); + const panes = client.listPanes(); + const byPurpose = new Map(tabs.filter((t) => t.purpose).map((t) => [t.purpose as string, t])); + + const statuses: ProjectStatus[] = projects.map((relPath) => { + const tab = byPurpose.get(projectPurpose(relPath)); + if (!tab) return { relPath, panes: 0, agent: "none" }; + const tabPanes = panes.filter((p) => p.tab_id === tab.position && !p.is_plugin); + const agentPane = tabPanes.find((p) => p.purpose?.startsWith("agent:")); + return { relPath, tab, panes: tabPanes.length, agent: agentStateOf(agentPane) }; + }); + + const plan = computePlan(ctx, tabs); + return { projects: statuses, stale: plan.close }; +} + +/** Ensure a tab exists for `name`, then focus it. */ +export function open(ctx: Context, name: string): TabInfo { + const { root, session } = ctx.resolved; + const client = ctx.client.forSession(session); + const relPath = resolveProjectName(ctx, name); + + if (!client.sessionExists(session)) { + throw new Error(`no session ${session}; run \`sl0ppi up\` first`); + } + + const purpose = projectPurpose(relPath); + let tab = client.listTabs().find((t) => t.purpose === purpose); + if (!tab) { + const position = client.newTab(singleTabLayoutKdl(specFor(root, relPath))); + ctx.log(`+ ${relPath}`); + tab = client.listTabs().find((t) => t.position === position) ?? { + position, + name: basename(relPath), + purpose, + }; + } + client.focusTab(tab.position); + return tab; +} + +export function close(ctx: Context, name: string): void { + const { session } = ctx.resolved; + const client = ctx.client.forSession(session); + const purpose = projectPurpose(resolveProjectName(ctx, name)); + const tab = client.listTabs().find((t) => t.purpose === purpose); + if (!tab) throw new Error(`no open tab for ${name}`); + client.closeTab(tab.position); + ctx.log(`- ${tab.name}`); +} + +/** + * Turn what the user typed into a project path. + * + * Exact match wins, then unique prefix. An ambiguous prefix is an error rather + * than a guess — picking one for them is how you end up closing the wrong tab. + */ +export function resolveProjectName(ctx: Context, name: string): string { + const { root } = ctx.resolved; + const projects = discoverProjects(root, { depth: ctx.depth }); + if (projects.includes(name)) return name; + + const asPath = resolve(ctx.resolved.root, name); + if (existsSync(join(asPath, MARKER_DIR))) { + const rel = asPath.startsWith(`${root}/`) ? asPath.slice(root.length + 1) : null; + if (rel) return rel; + } + + const matches = projects.filter((p) => p.startsWith(name) || basename(p) === name); + if (matches.length === 1) return matches[0] as string; + if (matches.length > 1) { + throw new Error(`ambiguous: ${name} matches ${matches.join(", ")}`); + } + throw new Error(`no project ${name} under ${root}`); +} + +function emptyPlan(): Plan { + return { create: [], close: [], keep: [], foreign: [] }; +} diff --git a/cli/src/core/layout.ts b/cli/src/core/layout.ts new file mode 100644 index 0000000..a1bf0bb --- /dev/null +++ b/cli/src/core/layout.ts @@ -0,0 +1,68 @@ +/** + * KDL composition. + * + * A project ships a *fragment* (pane nodes only); sl0ppi wraps it in the tab + * node that carries identity and cwd. Pure string composition, so there is no + * KDL parser to keep in step with the fork's dialect, and sl0ppi keeps sole + * control of the purpose. + * + * ALWAYS emit multi-line KDL. On a single line, KDL requires `;` terminators + * between nodes: `tab x { pane }` is a parse error, which is a genuinely + * confusing way to lose an afternoon. + */ + +import { projectPurpose } from "./purpose.ts"; + +/** Quote a value for a KDL string property. */ +export function kdlString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** + * What a project gets when it has a `.sl0ppi/` marker but no `layout.kdl`. + * + * One suspended agent: every project wants a main agent (agreed), and + * `start_suspended` is what stops `up` on a twelve-project root from launching + * twelve of them. The pane exists, focused-and-keypress runs it. + */ +export const DEFAULT_FRAGMENT = `pane purpose="agent:main" start_suspended=true command="pi"`; + +export interface TabSpec { + /** Tab name, shown in the tab bar. Cosmetic; identity is the purpose. */ + name: string; + /** Project path relative to the root, which the purpose encodes. */ + relPath: string; + /** Absolute path used as the tab's cwd. */ + cwd: string; + /** Pane nodes, verbatim from `.sl0ppi/layout.kdl`. */ + fragment?: string; +} + +function indent(text: string, spaces: number): string { + const pad = " ".repeat(spaces); + return text + .split("\n") + .map((line) => (line.trim() === "" ? "" : pad + line)) + .join("\n"); +} + +/** A single `tab { ... }` node, indented for embedding in a `layout` block. */ +export function tabKdl(spec: TabSpec, indentBy = 4): string { + const fragment = (spec.fragment ?? DEFAULT_FRAGMENT).trimEnd(); + const head = + `tab name=${kdlString(spec.name)} ` + + `purpose=${kdlString(projectPurpose(spec.relPath))} ` + + `cwd=${kdlString(spec.cwd)} {`; + return indent(`${head}\n${indent(fragment, 4)}\n}`, indentBy); +} + +/** A whole-session layout: what `up` passes to `--layout` when creating. */ +export function sessionLayoutKdl(specs: TabSpec[]): string { + const tabs = specs.map((spec) => tabKdl(spec)).join("\n"); + return `layout {\n${tabs}\n}\n`; +} + +/** A one-tab layout: what `up` passes to `new-tab --layout-string`. */ +export function singleTabLayoutKdl(spec: TabSpec): string { + return `layout {\n${tabKdl(spec)}\n}\n`; +} diff --git a/cli/src/core/root.ts b/cli/src/core/root.ts new file mode 100644 index 0000000..8e91d7f --- /dev/null +++ b/cli/src/core/root.ts @@ -0,0 +1,116 @@ +/** + * Roots and session names. + * + * One root directory = one sl0ppij session. The session name is derived from + * the root's basename so that typing `sl0ppi` in ~/dev always lands in the same + * session instead of minting another randomly-named one. + * + * The registry (session name -> root path) exists because a tab purpose stores + * a path *relative to the root*, and `list-panes --json` exposes no cwd, so + * without it there is no way back from a session to a directory. It is a + * rebuildable index, not a source of truth: delete it and the next `up --root` + * writes it again. It doubles as the record of which sessions are ours, which + * is how sl0ppi avoids touching sessions it did not create. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; +import { isProjectDir } from "./discover.ts"; + +export function configDir(env: NodeJS.ProcessEnv = process.env): string { + return env.SL0PPI_CONFIG_DIR ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "sl0ppi"); +} + +export function registryPath(env?: NodeJS.ProcessEnv): string { + return join(configDir(env), "roots.json"); +} + +export type Registry = Record<string, string>; + +export function loadRegistry(env?: NodeJS.ProcessEnv): Registry { + const path = registryPath(env); + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return typeof parsed === "object" && parsed !== null ? (parsed as Registry) : {}; + } catch { + return {}; // missing or corrupt: it is an index, so rebuild rather than fail + } +} + +export function saveRegistry(registry: Registry, env?: NodeJS.ProcessEnv): void { + const path = registryPath(env); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`); +} + +/** + * Session name for a root. + * + * Sanitised to something safe to type and to pass as a session name, and + * disambiguated with a short hash when another root already claims the name — + * two checkouts both called `api` must not fight over one session. + */ +export function sessionNameFor(root: string, registry: Registry = {}): string { + const cleaned = basename(resolve(root)).replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, ""); + const base = cleaned.length > 0 ? cleaned : "root"; + const claimed = registry[base]; + if (claimed === undefined || resolve(claimed) === resolve(root)) return base; + return `${base}-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 6)}`; +} + +export interface ResolvedRoot { + root: string; + session: string; + /** Set when the CWD is itself a project, so `sl0ppi` can focus its tab. */ + project?: string; +} + +/** + * Work out which root the user means. + * + * --root DIR -> that, explicitly + * inside a project-> its parent is the root, and we remember which project + * inside a session-> the root the session is registered against + * otherwise -> the cwd is the root + */ +export function resolveRoot(options: { + rootFlag?: string; + cwd: string; + sessionEnv?: string | null; + registry?: Registry; +}): ResolvedRoot { + const registry = options.registry ?? {}; + const cwd = resolve(options.cwd); + + if (options.rootFlag) { + const root = resolve(options.rootFlag); + return { root, session: sessionNameFor(root, registry) }; + } + + if (isProjectDir(cwd)) { + const root = dirname(cwd); + return { root, session: sessionNameFor(root, registry), project: basename(cwd) }; + } + + if (options.sessionEnv && registry[options.sessionEnv]) { + const root = resolve(registry[options.sessionEnv] as string); + return { root, session: options.sessionEnv }; + } + + return { root: cwd, session: sessionNameFor(cwd, registry) }; +} + +/** Record that `session` belongs to `root`, so later runs can find it. */ +export function rememberRoot(session: string, root: string, env?: NodeJS.ProcessEnv): void { + const registry = loadRegistry(env); + const resolved = resolve(root); + if (registry[session] === resolved) return; + registry[session] = resolved; + saveRegistry(registry, env); +} + +export function rootExists(root: string): boolean { + return existsSync(root); +} diff --git a/cli/src/core/sl0ppij.ts b/cli/src/core/sl0ppij.ts new file mode 100644 index 0000000..b8442b2 --- /dev/null +++ b/cli/src/core/sl0ppij.ts @@ -0,0 +1,205 @@ +/** + * Thin wrapper around the sl0ppij binary. + * + * sl0ppi orchestrates, it does not reimplement: every piece of state comes back + * out of `list-tabs`/`list-panes`, so there is nothing here to desync. + * + * Index conventions, both verified against a live session rather than inferred, + * because getting either wrong closes or focuses the wrong tab: + * + * list-tabs `position` 0-based + * close-tab --tab-id 0-based (same numbering as `position`) + * go-to-tab <INDEX> 1-BASED (matches the `GoToTab 1` keybinds) + * + * Closing also RENUMBERS the tabs after it, so a batch of closes must run in + * descending position order. + */ + +import { spawnSync } from "node:child_process"; + +export const DEFAULT_BIN = "sl0ppij"; + +export interface TabInfo { + position: number; + name: string; + purpose?: string; + active?: boolean; +} + +export interface PaneInfo { + id: number; + tab_id: number; + is_plugin: boolean; + title: string; + purpose?: string; + exited?: boolean; + is_held?: boolean; + terminal_command?: string | null; +} + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +export class Sl0ppijError extends Error { + constructor( + message: string, + readonly result: RunResult, + ) { + super(message); + this.name = "Sl0ppijError"; + } +} + +/** + * The variables that tell a sl0ppij process "you are inside a session". + * + * Both spellings, because the fork exports `SL0PPIJ*` and keeps `ZELLIJ*` for + * ecosystem compatibility. + */ +export const SESSION_ENV_KEYS = [ + "SL0PPIJ", + "SL0PPIJ_SESSION_NAME", + "SL0PPIJ_PANE_ID", + "ZELLIJ", + "ZELLIJ_SESSION_NAME", + "ZELLIJ_PANE_ID", +] as const; + +/** A copy of `env` with the "inside a session" markers removed. */ +export function withoutSessionEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const copy = { ...env }; + for (const key of SESSION_ENV_KEYS) delete copy[key]; + return copy; +} + +export interface ClientOptions { + bin?: string; + session?: string; +} + +export class Sl0ppij { + readonly bin: string; + readonly session?: string; + + constructor(options: ClientOptions = {}) { + this.bin = options.bin ?? process.env.SL0PPI_BIN ?? DEFAULT_BIN; + this.session = options.session; + } + + /** Same client, pinned to a session. */ + forSession(session: string): Sl0ppij { + return new Sl0ppij({ bin: this.bin, session }); + } + + run( + args: string[], + { session = true, detached = false }: { session?: boolean; detached?: boolean } = {}, + ): RunResult { + const argv = session && this.session ? ["-s", this.session, ...args] : args; + if (process.env.SL0PPI_DEBUG) { + process.stderr.write(`sl0ppi: exec ${this.bin} ${argv.map((a) => JSON.stringify(a)).join(" ")}\n`); + } + const proc = spawnSync(this.bin, argv, { + encoding: "utf8", + env: detached ? withoutSessionEnv() : process.env, + }); + if (proc.error) { + throw new Sl0ppijError(`could not run ${this.bin}: ${proc.error.message}`, { + code: -1, + stdout: "", + stderr: String(proc.error.message), + }); + } + return { + code: proc.status ?? -1, + stdout: proc.stdout ?? "", + stderr: proc.stderr ?? "", + }; + } + + private runOk( + args: string[], + what: string, + opts?: { session?: boolean; detached?: boolean }, + ): RunResult { + const result = this.run(args, opts); + if (result.code !== 0) { + throw new Sl0ppijError(`${what} failed: ${result.stderr.trim() || result.stdout.trim()}`, result); + } + return result; + } + + listTabs(): TabInfo[] { + const { stdout } = this.runOk(["action", "list-tabs", "--json"], "list-tabs"); + return JSON.parse(stdout) as TabInfo[]; + } + + listPanes(): PaneInfo[] { + const { stdout } = this.runOk(["action", "list-panes", "--json"], "list-panes"); + return JSON.parse(stdout) as PaneInfo[]; + } + + /** Adds a tab from a KDL layout string. Returns its new position. */ + newTab(layoutKdl: string): number { + const { stdout } = this.runOk( + ["action", "new-tab", "--layout-string", layoutKdl], + "new-tab", + ); + return Number.parseInt(stdout.trim(), 10); + } + + /** `position` is the 0-based `position` from list-tabs. */ + closeTab(position: number): void { + this.runOk(["action", "close-tab", "--tab-id", String(position)], "close-tab"); + } + + /** Takes a 0-based position and converts to go-to-tab's 1-based index. */ + focusTab(position: number): void { + this.runOk(["action", "go-to-tab", String(position + 1)], "go-to-tab"); + } + + /** + * Live sessions, excluding dead-but-resurrectable ones. + * + * `--short` lists EXITED sessions too with no way to tell them apart, so we + * parse the long form and drop them. (The same trap is documented in the + * pane-finder installer.) + */ + liveSessions(): string[] { + const result = this.run(["list-sessions", "--no-formatting"], { session: false }); + if (result.code !== 0) return []; // "no sessions" is an error exit, not a failure + return result.stdout + .split("\n") + .filter((line) => line.trim() !== "" && !line.includes("EXITED")) + .map((line) => line.trim().split(/\s+/)[0] as string) + .filter((name) => name.length > 0); + } + + sessionExists(name: string): boolean { + return this.liveSessions().includes(name); + } + + /** + * Creates a detached session from a layout file, without attaching. + * + * Runs with the session markers stripped: `attach --create-background` fails + * with "There is no active session!" when the caller is itself inside a + * session, and sl0ppi is nearly always invoked from inside one. We are + * creating a new detached session, not nesting, so dropping the markers is + * exactly right. + */ + createBackground(name: string, layoutPath: string): void { + this.runOk(["--layout", layoutPath, "attach", "--create-background", name], "create session", { + session: false, + detached: true, + }); + } +} + +/** True when we are running inside a sl0ppij/zellij pane already. */ +export function insideSession(env: NodeJS.ProcessEnv = process.env): string | null { + return env.SL0PPIJ_SESSION_NAME ?? env.ZELLIJ_SESSION_NAME ?? null; +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..920c029 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,185 @@ +#!/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 { close, ls, open, 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 <project> ensure and focus a project's tab + sl0ppi close <project> close a project's tab + 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) + -n, --dry-run +`; + +interface Args { + command: string; + rest: string[]; + rootFlag?: string; + depth: number; + dryRun: boolean; +} + +export function parseArgs(argv: string[]): Args { + const rest: string[] = []; + let rootFlag: string | undefined; + let depth = 1; + let dryRun = 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 rest.push(arg); + } + + const known = new Set(["up", "ls", "open", "close", "help"]); + const command = rest.length > 0 && known.has(rest[0] as string) ? (rest.shift() as string) : "up"; + return { command, rest, rootFlag, depth: Number.isFinite(depth) ? depth : 1, dryRun }; +} + +/** + * 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 OUR_VERBS = new Set(["up", "ls", "open", "close", "help"]); +const OUR_FLAGS = new Set(["--root", "--depth", "-n", "--dry-run"]); + +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}\nsl0ppi: falling through to sl0ppij\n`); + const bin = process.env.SL0PPI_BIN ?? DEFAULT_BIN; + const proc = spawnSync(bin, passthroughArgs(argv), { 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, + 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; + } + default: { + up(ctx); + // 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; + // 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. + if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stdout.write(`session ${ctx.resolved.session} ready (not a tty; not attaching)\n`); + return; + } + const bin = process.env.SL0PPI_BIN ?? DEFAULT_BIN; + const proc = spawnSync(bin, ["attach", ctx.resolved.session], { stdio: "inherit" }); + process.exit(proc.status ?? 0); + } + } + } catch (error) { + fallThrough(argv, describe(error)); + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +if (import.meta.main) main(); |
