1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
|
#!/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 <project> ensure and focus a project's tab
sl0ppi close <project> close a project's tab
sl0ppi init [dir] make a directory a project (writes .sl0ppi/layout.kdl)
sl0ppi print <project> 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();
|