directionallylocal 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,20 +2,26 @@ import { readFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { parseArgs } from "node:util";
4
4
  import { doctor, formatChecks } from "./doctor.js";
5
- import { briefStatus, connectLaptop, laptopsCommand, logsCommand, serverHome, serverRunsCommand } from "./home.js";
5
+ import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, serverHome, serverRunsCommand } from "./home.js";
6
6
  import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, watch } from "./laptop/commands.js";
7
+ import { loadRemotes } from "./laptop/config.js";
8
+ import { setup } from "./laptop/setup.js";
9
+ import { localCommand } from "./local/cli.js";
10
+ import { setUpOnThisComputer } from "./local/setup.js";
7
11
  import { TtyPrompter } from "./prompt.js";
8
12
  import { readState } from "./state.js";
9
13
  import { DryRunSystem, RealSystem } from "./system.js";
10
14
  import { uninstall } from "./uninstall.js";
11
- import { PlainUi, TerminalUi, palette } from "./ui.js";
12
- import { up } from "./up.js";
15
+ import { JsonUi, PlainUi, TerminalUi, palette } from "./ui.js";
16
+ import { needs, parseAccessMode, parseProvidedAnswers, up } from "./up.js";
13
17
  const HELP = `Directionally Local: background coding agents on your own server.
14
18
  Send a task from your laptop; it runs in its own sandbox and comes back as a pull request.
15
19
  (dl is short for directionallylocal; both work.)
16
20
 
17
21
  On your laptop:
18
- dl connect URL connect to your server (approve the code it shows)
22
+ dl set up where agents run (the first time; afterwards, an overview)
23
+ dl setup set up, or repair, a server over SSH (safe to re-run)
24
+ dl connect URL connect to a server someone else set up (approve the code it shows)
19
25
  dl run "TASK" run a task on the server, from inside a GitHub repository
20
26
  (your uncommitted changes go along; Ctrl-C stops watching, not the task)
21
27
  dl runs your tasks and their pull requests
@@ -23,6 +29,8 @@ On your laptop:
23
29
  dl pull ID check out a task's branch here
24
30
  dl servers connected servers (dl servers remove NAME)
25
31
  run options: --server NAME, -m MODEL, --repo OWNER/NAME, --detach
32
+ dl local status agents running on this computer: running?, where, how many fit
33
+ dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
26
34
 
27
35
  On the server:
28
36
  dl what this is, whether it's healthy, laptops, and runs
@@ -46,7 +54,14 @@ Unattended installs answer the questions with environment variables:
46
54
 
47
55
  Advanced:
48
56
  DL_ACCESS=loopback don't expose the server; reach it over an SSH tunnel
57
+ (or --access tailscale|loopback|ssh; ssh is the laptop's setup)
49
58
  DL_SKIP_CREDENTIAL_CHECK=1 don't check the keys with OpenRouter and GitHub (offline installs)
59
+
60
+ Used by the laptop's setup (over SSH, as root):
61
+ dl up --access ssh --answers-stdin --events json
62
+ install with the keys as one JSON object on stdin; progress as JSON lines
63
+ dl up --needs [--access ssh] which keys dl up would still need, as JSON; changes nothing
64
+ dl pair --name NAME --json a credential for that laptop, as JSON
50
65
  `;
51
66
  export async function main(argv, io = {
52
67
  out: (text) => process.stdout.write(text),
@@ -73,6 +88,10 @@ export async function main(argv, io = {
73
88
  repo: { type: "string" },
74
89
  detach: { type: "boolean" },
75
90
  json: { type: "boolean" },
91
+ access: { type: "string" },
92
+ "answers-stdin": { type: "boolean" },
93
+ events: { type: "string" },
94
+ needs: { type: "boolean" },
76
95
  },
77
96
  });
78
97
  }
@@ -90,27 +109,84 @@ export async function main(argv, io = {
90
109
  io.out(HELP);
91
110
  return 0;
92
111
  }
93
- const log = (line) => io.out(`${line}\n`);
112
+ if (values.events !== undefined && values.events !== "json") {
113
+ io.err(`--events takes json (got "${values.events}").\n\n${HELP}`);
114
+ return 2;
115
+ }
116
+ let access;
117
+ try {
118
+ access = values.access === undefined ? undefined : parseAccessMode(values.access);
119
+ }
120
+ catch (error) {
121
+ io.err(`${error.message}\n`);
122
+ return 2;
123
+ }
124
+ // `--events json`: stdout carries only the laptop's JSON events; everything else goes to
125
+ // stderr, and other tools' output to the install log, as with the live checklist.
126
+ const json = command === "up" && values.events === "json" && !values.needs;
127
+ const log = (line) => (json ? io.err : io.out)(`${line}\n`);
94
128
  // The live checklist needs a terminal; a dry run prints its plan line by line instead.
95
129
  const terminal = options.terminal ?? (process.stdout.isTTY === true);
96
- const live = terminal && command === "up" && !values["dry-run"];
97
- const ui = live ? new TerminalUi(io.out) : new PlainUi(io.out);
98
- const colorOn = terminal && !process.env.NO_COLOR && process.env.TERM !== "dumb";
130
+ // --needs only looks: it mustn't even start a new install log.
131
+ const live = terminal && command === "up" && !values["dry-run"] && !values.needs && !json;
132
+ const jsonUi = json ? new JsonUi(io.out) : undefined;
133
+ const ui = jsonUi ?? (live ? new TerminalUi(io.out) : new PlainUi(io.out));
134
+ const colorOn = terminal && !json && !process.env.NO_COLOR && process.env.TERM !== "dumb";
99
135
  const colors = palette(colorOn);
100
136
  const laptopIO = { out: io.out, err: io.err, color: colorOn };
101
- const system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, live ? { outputLog: UP_LOG } : {});
137
+ const logged = (live || json) && !values["dry-run"];
138
+ let system;
139
+ try {
140
+ system = values["dry-run"] ? new DryRunSystem(log) : new RealSystem(log, logged ? { outputLog: UP_LOG } : {});
141
+ }
142
+ catch (error) {
143
+ // Opening the install log is the first write, so this is where a non-root `up` stops.
144
+ const message = `Couldn't open ${UP_LOG}: ${describeError(error)}. Run it as root: sudo dl up`;
145
+ if (jsonUi)
146
+ jsonUi.error(message);
147
+ else
148
+ io.err(`\n ${palette(live && !process.env.NO_COLOR).red("✗")} ${message}\n`);
149
+ return 1;
150
+ }
102
151
  try {
103
152
  switch (command) {
104
- case "up":
153
+ case "up": {
154
+ if (values.needs) {
155
+ io.out(`${JSON.stringify({ needs: await needs(system, access ? { access } : {}) })}\n`);
156
+ return 0;
157
+ }
158
+ // Read before anything else happens, so a bad answer fails before any change.
159
+ const answers = values["answers-stdin"] ? parseProvidedAnswers(await readAnswers(options.stdin ?? process.stdin)) : undefined;
105
160
  if (system.dryRun)
106
161
  log("Dry run: nothing on this machine will change.\n");
107
162
  await up(system, {
108
163
  prompter: new TtyPrompter(),
109
164
  ui,
165
+ ...(access ? { access } : {}),
166
+ ...(answers ? { answers } : {}),
110
167
  ...(values["autopilot-version"] ? { autopilotVersion: values["autopilot-version"] } : {}),
111
168
  ...(values["hotcell-version"] ? { hotcellVersion: values["hotcell-version"] } : {}),
112
169
  });
113
170
  return 0;
171
+ }
172
+ case "pair": {
173
+ // Only the laptop's setup runs this, and it reads the JSON; there's no human form.
174
+ if (!values.json || !values.name?.trim()) {
175
+ io.err(` Usage: dl pair --name NAME --json ${colors.dim("(run by the laptop's setup, over SSH)")}\n`);
176
+ return 2;
177
+ }
178
+ const state = await readState(system);
179
+ if (!state) {
180
+ io.err(" Directionally Local isn't installed on this server yet; run dl up first.\n");
181
+ return 1;
182
+ }
183
+ if (!isRoot()) {
184
+ io.err(" Run it as root: sudo dl pair --name NAME --json\n");
185
+ return 1;
186
+ }
187
+ await pairLaptop(system, state, values.name, io.out);
188
+ return 0;
189
+ }
114
190
  case "doctor": {
115
191
  const checks = await doctor(system);
116
192
  log(formatChecks(checks, terminal && !process.env.NO_COLOR));
@@ -125,6 +201,9 @@ export async function main(argv, io = {
125
201
  // A laptop connected to servers gets its own home; otherwise, what this is and how to start.
126
202
  if (!command && await laptopHome(laptopIO))
127
203
  return 0;
204
+ // A laptop with nothing set up yet: the wizard (it needs someone to answer it).
205
+ if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
206
+ return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
128
207
  io.out(NOT_A_SERVER(colors));
129
208
  return command ? 1 : 0;
130
209
  }
@@ -140,6 +219,8 @@ export async function main(argv, io = {
140
219
  await serverHome(system, state, io.out, colors);
141
220
  return 0;
142
221
  }
222
+ case "setup":
223
+ return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
143
224
  case "connect": {
144
225
  const url = parsed.positionals[1];
145
226
  if (!url) {
@@ -212,6 +293,8 @@ export async function main(argv, io = {
212
293
  return await serverRunsCommand(system, io.out, colors);
213
294
  return await logsCommand(system, [...rest, ...(values.follow ? ["-f"] : [])]);
214
295
  }
296
+ case "local":
297
+ return await localCommand(parsed.positionals.slice(1), { purge: Boolean(values.purge) }, io, colors, ui);
215
298
  case "uninstall":
216
299
  await uninstall(system, { purge: Boolean(values.purge) });
217
300
  return 0;
@@ -222,6 +305,12 @@ export async function main(argv, io = {
222
305
  }
223
306
  catch (error) {
224
307
  ui.finish();
308
+ if (jsonUi) {
309
+ // The event is what the laptop shows; the log's tail on stderr is for whoever looks.
310
+ jsonUi.error(describeError(error));
311
+ io.err(await logTail(UP_LOG, palette(false)));
312
+ return 1;
313
+ }
225
314
  const c = palette(live && !process.env.NO_COLOR);
226
315
  io.err(`\n ${c.red("✗")} ${describeError(error).replace(/\n/g, "\n ")}\n`);
227
316
  if (live)
@@ -235,13 +324,43 @@ const NOT_A_SERVER = (c) => `
235
324
  Background coding agents on your own server: send a task from your laptop,
236
325
  get a pull request back.
237
326
 
238
- ${c.dim("Set up a server (as root, on Ubuntu or Debian):")}
327
+ ${c.dim("Set it up from your laptop (asks a few questions):")}
328
+ dl setup
329
+
330
+ ${c.dim("Or on a server (as root, on Ubuntu or Debian):")}
239
331
  curl -fsSL https://directionallylocal.run | sh
240
332
 
241
333
  ${c.dim("Already have one? Connect this laptop to it:")}
242
334
  dl connect https://your-server
243
335
 
244
336
  `;
337
+ /**
338
+ * The `--answers-stdin` JSON: everything up to the end of stdin, or sooner once what came
339
+ * so far parses (a laptop that keeps the SSH channel open still gets going). Keys arrive
340
+ * this way so they're never in argv, the environment, or a file.
341
+ */
342
+ export async function readAnswers(stdin) {
343
+ if (stdin.isTTY)
344
+ throw new Error("--answers-stdin reads one JSON object from a pipe, not a terminal.");
345
+ let text = "";
346
+ for await (const chunk of stdin) {
347
+ text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
348
+ if (text.length > 64 * 1024)
349
+ throw new Error("The answers on stdin are too long; expected one small JSON object.");
350
+ if (text.includes("\n") && parses(text))
351
+ return text;
352
+ }
353
+ return text;
354
+ }
355
+ function parses(text) {
356
+ try {
357
+ JSON.parse(text);
358
+ return true;
359
+ }
360
+ catch {
361
+ return false;
362
+ }
363
+ }
245
364
  /** Ctrl-C aborts the command (a running task keeps going on the server). */
246
365
  async function interruptible(work) {
247
366
  const controller = new AbortController();
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { detectHost, MIN_DISK_GB } from "./detect.js";
2
2
  import { nodeAdvisory } from "./node-advisory.js";
3
3
  import { AUTOPILOT_PORT, AUTO_UPGRADES, HOTCELL_PORT, NODE_AUTO_UPDATES, PATHS, SERVICES, parseEnvFile } from "./layout.js";
4
- import { readState } from "./state.js";
4
+ import { describeAccess, readState } from "./state.js";
5
5
  import { palette } from "./ui.js";
6
6
  /** `directionallylocal doctor`: every moving part, with a fix for each failure. */
7
7
  export async function doctor(system) {
@@ -9,7 +9,7 @@ export async function doctor(system) {
9
9
  const add = (check) => checks.push(check);
10
10
  const state = await readState(system);
11
11
  add(state
12
- ? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode} at ${state.access.url}` }
12
+ ? { name: "installed", ok: true, detail: `${state.release}, ${state.access.mode === "ssh" ? describeAccess(state.access) : `${state.access.mode} at ${state.access.url}`}` }
13
13
  : { name: "installed", ok: false, detail: "not installed", fix: "sudo dl up" });
14
14
  const facts = await detectHost(system);
15
15
  const node = nodeAdvisory(facts.nodeVersion ?? "");
@@ -49,6 +49,7 @@ export async function doctor(system) {
49
49
  add({ name: "github", ok: github, detail: github ? "token configured" : "no token; runs can't push or open PRs", fix: "sudo dl up (asks for it)" });
50
50
  const autopilot = (await system.probe("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${AUTOPILOT_PORT}/api/v1/health`])).stdout.trim();
51
51
  add({ name: "agent server API", ok: autopilot === "401", detail: autopilot === "401" ? "up (refuses requests without a token)" : `HTTP ${autopilot || "none"}`, fix: "dl logs autopilot · then dl up" });
52
+ // Loopback and SSH access have nothing published to check: the tunnel is the laptop's.
52
53
  if (state?.access.mode === "tailscale") {
53
54
  const serve = await system.probe("tailscale", ["serve", "status"]);
54
55
  const serving = serve.code === 0 && serve.stdout.includes(`127.0.0.1:${AUTOPILOT_PORT}`);
package/dist/home.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { agentCapacity, detectHost } from "./detect.js";
2
2
  import { AUTOPILOT_PORT, CELL_MEMORY_MB, SERVICES } from "./layout.js";
3
- import { approvePairing, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
4
- import { readState } from "./state.js";
3
+ import { approvePairing, createLaptopCredential, listLaptops, listServerRuns, ownerLink, pendingPairings, revokeLaptop } from "./server-api.js";
4
+ import { describeAccess, readState } from "./state.js";
5
5
  import { palette } from "./ui.js";
6
6
  import { nextSteps } from "./up.js";
7
7
  const ABOUT = "Background coding agents on your own server: send a task from your laptop, get a pull request back.";
@@ -29,7 +29,7 @@ export async function serverHome(system, state, write, c) {
29
29
  const running = runs?.filter((run) => run.active).length ?? 0;
30
30
  const doneThisWeek = runs?.filter((run) => !run.active && Date.parse(run.createdAt) >= week).length ?? 0;
31
31
  const row = (label, value, hint = "") => ` ${c.dim(label.padEnd(11))}${value}${hint ? ` ${c.dim(hint)}` : ""}\n`;
32
- write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(state.access.url)}\n\n ${ABOUT}\n\n`);
32
+ write(`\n ${c.bold("Directionally Local")} ${verdict} ${c.dim(describeAccess(state.access))}\n\n ${ABOUT}\n\n`);
33
33
  write(row("Laptops", laptops === undefined ? c.dim("unknown (run dl up to update)") : laptops.length === 0 ? "none connected yet" : `${laptops.length} connected · ${laptops.map((laptop) => laptop.name).join(", ")}`, "dl laptops"));
34
34
  write(row("Runs", runs === undefined ? c.dim("unknown (run dl up to update)") : `${running} running · ${doneThisWeek} this week`, "dl runs"));
35
35
  write(row("Capacity", `~${agentCapacity(facts, CELL_MEMORY_MB)} agents at once (estimate)`));
@@ -41,7 +41,7 @@ export async function serverHome(system, state, write, c) {
41
41
  /** `dl status --brief`: one line, for the login message. Never slow, never fails loudly. */
42
42
  export async function briefStatus(system, state, write, c) {
43
43
  const status = await health(system);
44
- write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(state.access.url)}\n ${c.dim("Type")} dl ${c.dim("for what this is and what's running.")}\n\n`);
44
+ write(`\n ${c.bold("Directionally Local")} ${status.ok ? c.green("● healthy") : c.red(`● ${status.problems.join(", ")}`)} ${c.dim(describeAccess(state.access))}\n ${c.dim("Type")} dl ${c.dim("for what this is and what's running.")}\n\n`);
45
45
  }
46
46
  /**
47
47
  * `dl connect-laptop [CODE]`: approve a laptop's code, or explain how to connect one and
@@ -65,6 +65,18 @@ export async function connectLaptop(system, state, code, write, c) {
65
65
  write(`\n ${c.dim("The laptop shows a code; approve it in that browser, or here with")} dl connect-laptop <code>\n\n`);
66
66
  return 0;
67
67
  }
68
+ /**
69
+ * `dl pair --name NAME --json`: a credential for the laptop that's setting this server up
70
+ * over SSH, printed as one JSON line for it to keep. The token appears nowhere else.
71
+ */
72
+ export async function pairLaptop(system, state, name, write) {
73
+ const trimmed = name.trim();
74
+ if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed)) {
75
+ throw new Error("--name must be 1 to 100 printable characters, e.g. the laptop's hostname.");
76
+ }
77
+ const paired = await createLaptopCredential(system, trimmed, state.access.url);
78
+ write(`${JSON.stringify({ token: paired.token, credentialId: paired.credentialId, name: paired.name, expiresAt: paired.expiresAt, url: paired.url })}\n`);
79
+ }
68
80
  export async function laptopsCommand(system, args, write, c) {
69
81
  const laptops = await listLaptops(system);
70
82
  if (laptops === undefined) {
@@ -6,11 +6,15 @@ import { palette } from "../ui.js";
6
6
  import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
7
7
  import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
8
8
  import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
9
+ import { sshCommandLine } from "./ssh.js";
9
10
  import { openBrowser } from "./open-browser.js";
11
+ import { withConnections } from "./tunnel.js";
10
12
  /**
11
13
  * `dl` on a laptop: connect to a server, send it tasks, follow them, and check out the
12
14
  * pull requests. It talks to the server's API directly (the same one Autopilot's
13
15
  * `--on` uses, and shares its list of servers), so the laptop needs nothing else.
16
+ * Servers set up with `dl setup` are reached through an SSH tunnel that each command
17
+ * opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
14
18
  */
15
19
  const execFileAsync = promisify(execFile);
16
20
  const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
@@ -56,9 +60,11 @@ export async function connect(input, io = processIO) {
56
60
  * checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
57
61
  */
58
62
  export async function run(input, io = processIO) {
59
- const c = palette(io.color === true);
60
63
  const remote = await resolveRemote(input.server);
61
- const client = new RemoteClient(remote.url, remote.token);
64
+ return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, input, io));
65
+ }
66
+ async function runOn(client, remote, input, io) {
67
+ const c = palette(io.color === true);
62
68
  const repository = input.repository ?? await detectGitHubRepository(input.cwd);
63
69
  const snapshot = await snapshotWorkingTree(input.cwd);
64
70
  const [workspaces, models] = await Promise.all([client.workspaces(), client.models()]);
@@ -88,14 +94,17 @@ export async function run(input, io = processIO) {
88
94
  }
89
95
  /** `dl runs`: tasks on every connected server, newest first. */
90
96
  export async function runs(input, io = processIO) {
91
- const c = palette(io.color === true);
92
97
  const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
93
98
  if (remotes.length === 0)
94
99
  throw new Error("This laptop isn't connected to a server yet. Run: dl connect https://your-server");
100
+ return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
101
+ }
102
+ async function listRuns(remotes, connections, json, io) {
103
+ const c = palette(io.color === true);
95
104
  const rows = [];
96
105
  for (const remote of remotes) {
97
106
  try {
98
- const list = await new RemoteClient(remote.url, remote.token).listConversations();
107
+ const list = await (await connections.client(remote)).listConversations();
99
108
  rows.push(...list.filter((entry) => entry.kind === "code").map((entry) => ({ ...entry, server: remote.name })));
100
109
  }
101
110
  catch (error) {
@@ -103,7 +112,7 @@ export async function runs(input, io = processIO) {
103
112
  }
104
113
  }
105
114
  rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
106
- if (input.json) {
115
+ if (json) {
107
116
  io.out(`${JSON.stringify(rows, null, 2)}\n`);
108
117
  return;
109
118
  }
@@ -122,21 +131,26 @@ export async function runs(input, io = processIO) {
122
131
  }
123
132
  /** `dl watch <id>`: replay and follow a task's output. */
124
133
  export async function watch(input, io = processIO) {
125
- const { remote, run: found } = await findRun(input.id, input.server);
126
- const client = new RemoteClient(remote.url, remote.token);
127
- const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
128
- return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
134
+ return withConnections(input.open, async (connections) => {
135
+ const { remote, run: found } = await findRun(input.id, input.server, connections);
136
+ const client = await connections.client(remote);
137
+ const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
138
+ return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
139
+ });
129
140
  }
130
141
  export async function cancel(input, io = processIO) {
131
142
  const c = palette(io.color === true);
132
- const { remote, run: found } = await findRun(input.id, input.server);
133
- await new RemoteClient(remote.url, remote.token).cancel(found.conversationId);
134
- io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
143
+ await withConnections(input.open, async (connections) => {
144
+ const { remote, run: found } = await findRun(input.id, input.server, connections);
145
+ await (await connections.client(remote)).cancel(found.conversationId);
146
+ io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
147
+ });
135
148
  }
136
149
  /** `dl pull <id>`: check out a task's branch here. */
137
150
  export async function pull(input, io = processIO) {
138
151
  const c = palette(io.color === true);
139
- const { run: found } = await findRun(input.id, input.server);
152
+ // The server is only asked for the branch name; the code itself comes from GitHub.
153
+ const { run: found } = await withConnections(input.open, (connections) => findRun(input.id, input.server, connections));
140
154
  const branch = found.branch;
141
155
  if (!branch)
142
156
  throw new Error("That task has no branch");
@@ -164,40 +178,44 @@ export async function servers(args, io = processIO) {
164
178
  const c = palette(io.color === true);
165
179
  if (args[0] === "remove") {
166
180
  const name = args[1] ?? "";
167
- if (!(await removeRemote(name))) {
181
+ const removed = (await loadRemotes()).find((remote) => remote.name === name);
182
+ if (!removed || !(await removeRemote(name))) {
168
183
  io.err(` ${c.red("✗")} Not connected to "${name}".\n`);
169
184
  return 1;
170
185
  }
171
- io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: sudo dl laptops remove "${clientName()}"\n`);
186
+ const onServer = `sudo dl laptops remove "${clientName()}"`;
187
+ io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: ${removed.ssh ? `${sshCommandLine(removed.ssh)} '${onServer}'` : onServer}\n`);
172
188
  return 0;
173
189
  }
174
190
  const remotes = await loadRemotes();
175
191
  if (remotes.length === 0) {
176
- io.out(`\n Not connected to a server yet. ${c.bold("dl connect https://your-server")}\n\n`);
192
+ io.out(`\n Not connected to a server yet. ${c.bold("dl setup")} ${c.dim("or, for a server someone else set up:")} ${c.bold("dl connect https://your-server")}\n\n`);
177
193
  return 0;
178
194
  }
179
195
  io.out("\n");
180
196
  for (const remote of remotes)
181
- io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${remote.url} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
197
+ io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
182
198
  io.out("\n");
183
199
  return 0;
184
200
  }
185
201
  /** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
186
- export async function laptopHome(io = processIO) {
202
+ export async function laptopHome(io = processIO, open) {
187
203
  const c = palette(io.color === true);
188
204
  const remotes = await loadRemotes();
189
205
  if (remotes.length === 0)
190
206
  return false;
191
207
  let running = 0;
192
208
  let total = 0;
193
- for (const remote of remotes) {
194
- const list = await new RemoteClient(remote.url, remote.token).listConversations().catch(() => undefined);
195
- running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
196
- total += list?.length ?? 0;
197
- }
209
+ await withConnections(open, async (connections) => {
210
+ for (const remote of remotes) {
211
+ const list = await connections.client(remote).then((client) => client.listConversations()).catch(() => undefined);
212
+ running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
213
+ total += list?.length ?? 0;
214
+ }
215
+ });
198
216
  io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
199
217
  io.out(" Background coding agents on your own server: send a task, get a pull request back.\n\n");
200
- io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(remote.url)}`).join(", ")} ${c.dim("dl servers")}\n`);
218
+ io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim("dl servers")}\n`);
201
219
  io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
202
220
  io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
203
221
  return true;
@@ -285,20 +303,25 @@ class EventRenderer {
285
303
  this.io.out(`${text}\n`);
286
304
  }
287
305
  }
288
- async function findRun(id, server) {
306
+ async function findRun(id, server, connections) {
289
307
  const needle = id.trim().toLowerCase();
290
308
  if (!/^[0-9a-f-]{4,36}$/.test(needle))
291
309
  throw new Error("Task IDs are the short hex IDs dl runs shows");
292
310
  const remotes = server ? [await resolveRemote(server)] : await loadRemotes();
293
311
  const matches = [];
312
+ const unreachable = [];
294
313
  for (const remote of remotes) {
295
- const list = await new RemoteClient(remote.url, remote.token).listConversations(200).catch(() => []);
314
+ const list = await connections.client(remote).then((client) => client.listConversations(200)).catch((error) => {
315
+ unreachable.push(`${remote.name}: ${describeError(error)}`);
316
+ return [];
317
+ });
296
318
  for (const entry of list)
297
319
  if (entry.conversationId.startsWith(needle))
298
320
  matches.push({ remote, run: entry });
299
321
  }
322
+ // Say why when a server couldn't be asked (e.g. its SSH tunnel failed), rather than "no task".
300
323
  if (matches.length === 0)
301
- throw new Error(`No task ${id} on ${server ?? "any connected server"}`);
324
+ throw new Error(`No task ${id} on ${server ?? "any connected server"}${unreachable.length ? `\n${unreachable.join("\n")}` : ""}`);
302
325
  if (matches.length > 1)
303
326
  throw new Error(`${id} matches several tasks; use more characters`);
304
327
  return matches[0];
@@ -316,6 +339,10 @@ function pickModel(models, requested, preferred) {
316
339
  throw new Error("The server offers no models");
317
340
  return first;
318
341
  }
342
+ /** Where a server is, as people know it: its URL, or the SSH destination its tunnel goes to. */
343
+ function where(remote) {
344
+ return remote.ssh ? `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}` : remote.url;
345
+ }
319
346
  function clientName() {
320
347
  const host = hostname().replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
321
348
  return `${host} (dl)`;
@@ -85,5 +85,15 @@ function isRemote(value) {
85
85
  if (!value || typeof value !== "object")
86
86
  return false;
87
87
  const entry = value;
88
- return ["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string");
88
+ if (!["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string"))
89
+ return false;
90
+ return entry.ssh === undefined || isSshAccess(entry.ssh);
91
+ }
92
+ function isSshAccess(value) {
93
+ if (!value || typeof value !== "object")
94
+ return false;
95
+ const ssh = value;
96
+ return typeof ssh.target === "string" && ssh.target.length > 0 && !ssh.target.startsWith("-") &&
97
+ (ssh.port === undefined || (typeof ssh.port === "number" && Number.isInteger(ssh.port) && ssh.port > 0 && ssh.port < 65536)) &&
98
+ (ssh.identityFile === undefined || typeof ssh.identityFile === "string");
89
99
  }
@@ -0,0 +1,129 @@
1
+ import { join } from "node:path";
2
+ /** Autopilot's config, where `autopilot` keeps the user's OpenRouter key. */
3
+ export function autopilotConfigPath(env, home) {
4
+ return join(env.XDG_CONFIG_HOME?.trim() || join(home, ".config"), "kimiflare", "config.json");
5
+ }
6
+ export async function validateOpenRouterKey(key, fetchImpl) {
7
+ if (!/^sk-or-[A-Za-z0-9_-]{10,}$/.test(key))
8
+ return "That doesn't look like an OpenRouter key (sk-or-…).";
9
+ if (!fetchImpl)
10
+ return undefined;
11
+ try {
12
+ const response = await fetchImpl("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` } });
13
+ if (response.status === 401 || response.status === 403)
14
+ return "OpenRouter rejected that key.";
15
+ }
16
+ catch {
17
+ // Offline: the format is right, and the server checks it again.
18
+ }
19
+ return undefined;
20
+ }
21
+ export async function validateGitHubToken(token, fetchImpl) {
22
+ if (!/^(github_pat_|ghp_|gho_|ghu_)[A-Za-z0-9_]{20,}$/.test(token))
23
+ return { problem: "That doesn't look like a GitHub token (github_pat_… or ghp_…)." };
24
+ if (!fetchImpl)
25
+ return {};
26
+ try {
27
+ const response = await fetchImpl("https://api.github.com/user", { headers: { Authorization: `Bearer ${token}`, "User-Agent": "directionallylocal" } });
28
+ if (response.status === 401)
29
+ return { problem: "GitHub rejected that token." };
30
+ if (response.ok) {
31
+ const user = await response.json();
32
+ if (typeof user.login === "string")
33
+ return { login: user.login };
34
+ }
35
+ }
36
+ catch {
37
+ // Offline isn't fatal.
38
+ }
39
+ return {};
40
+ }
41
+ /** Finds, confirms, or asks for exactly the keys in `needs`. */
42
+ export async function gatherKeys(needs, input) {
43
+ const { ui } = input;
44
+ const answers = {};
45
+ const wanted = needs.filter((need) => need === "github" || need === "openrouter");
46
+ if (wanted.length === 0)
47
+ return answers;
48
+ ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on your server only; then it sets itself up.`);
49
+ if (wanted.includes("github"))
50
+ answers.githubToken = await githubToken(input);
51
+ if (wanted.includes("openrouter"))
52
+ answers.openrouterKey = await openrouterKey(input);
53
+ return answers;
54
+ }
55
+ async function githubToken({ ui, prompter, sources }) {
56
+ const fromCli = (await sources.gh().catch(() => undefined))?.trim();
57
+ if (fromCli) {
58
+ const checked = await validateGitHubToken(fromCli, sources.fetch);
59
+ if (checked.problem) {
60
+ ui.warn(`Your GitHub CLI login didn't work (${checked.problem}); asking for a token instead.`);
61
+ }
62
+ else {
63
+ const who = checked.login ? ` (${checked.login})` : "";
64
+ if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on your server.`)) {
65
+ ui.answered("GitHub", `${checked.login ? `signed in as ${checked.login} · ` : ""}from the GitHub CLI`);
66
+ return fromCli;
67
+ }
68
+ }
69
+ }
70
+ return askUntilValid(ui, "GitHub", async () => {
71
+ const token = await prompter.secret(ui.prompt("GitHub token", "fine-grained: github.com/settings/personal-access-tokens/new"));
72
+ const checked = await validateGitHubToken(token, sources.fetch);
73
+ return { value: token, ...(checked.problem ? { problem: checked.problem } : {}), ...(checked.login ? { detail: `signed in as ${checked.login}` } : {}) };
74
+ });
75
+ }
76
+ async function openrouterKey({ ui, prompter, sources }) {
77
+ const candidates = [];
78
+ const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
79
+ if (fromEnv)
80
+ candidates.push({ key: fromEnv, question: "Use the OpenRouter key in OPENROUTER_API_KEY?", from: "from OPENROUTER_API_KEY" });
81
+ const config = await sources.readFile(autopilotConfigPath(sources.env, sources.home));
82
+ const fromAutopilot = config ? readAutopilotKey(config) : undefined;
83
+ if (fromAutopilot && fromAutopilot !== fromEnv)
84
+ candidates.push({ key: fromAutopilot, question: "Use the OpenRouter key from Autopilot?", from: "from Autopilot" });
85
+ for (const candidate of candidates) {
86
+ const problem = await validateOpenRouterKey(candidate.key, sources.fetch);
87
+ if (problem) {
88
+ ui.warn(`The OpenRouter key ${candidate.from} didn't work (${problem})`);
89
+ continue;
90
+ }
91
+ if (await confirm(ui, prompter, `${candidate.question} It stays on your server.`)) {
92
+ ui.answered("OpenRouter", candidate.from);
93
+ return candidate.key;
94
+ }
95
+ }
96
+ return askUntilValid(ui, "OpenRouter", async () => {
97
+ const key = await prompter.secret(ui.prompt("OpenRouter key", "openrouter.ai/keys"));
98
+ const problem = await validateOpenRouterKey(key, sources.fetch);
99
+ return { value: key, ...(problem ? { problem } : {}) };
100
+ });
101
+ }
102
+ function readAutopilotKey(text) {
103
+ try {
104
+ const value = JSON.parse(text).openrouterApiKey;
105
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
111
+ /** A Y/n question; Enter means yes. */
112
+ export async function confirm(ui, prompter, question) {
113
+ const answer = (await prompter.text(ui.prompt(question, "Y/n"))).trim().toLowerCase();
114
+ return answer === "" || answer === "y" || answer === "yes";
115
+ }
116
+ async function askUntilValid(ui, label, ask) {
117
+ for (let attempt = 1;; attempt++) {
118
+ const { value, problem, detail } = await ask();
119
+ if (!problem) {
120
+ ui.answered(label, detail);
121
+ return value.trim();
122
+ }
123
+ if (attempt >= 3) {
124
+ ui.rejected(problem);
125
+ throw new Error(problem);
126
+ }
127
+ ui.rejected(`${problem} Try again.`);
128
+ }
129
+ }