directionallylocal 0.2.0 → 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
@@ -4,6 +4,10 @@ import { parseArgs } from "node:util";
4
4
  import { doctor, formatChecks } from "./doctor.js";
5
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";
@@ -15,7 +19,9 @@ Send a task from your laptop; it runs in its own sandbox and comes back as a pul
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
@@ -193,6 +201,9 @@ export async function main(argv, io = {
193
201
  // A laptop connected to servers gets its own home; otherwise, what this is and how to start.
194
202
  if (!command && await laptopHome(laptopIO))
195
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) });
196
207
  io.out(NOT_A_SERVER(colors));
197
208
  return command ? 1 : 0;
198
209
  }
@@ -208,6 +219,8 @@ export async function main(argv, io = {
208
219
  await serverHome(system, state, io.out, colors);
209
220
  return 0;
210
221
  }
222
+ case "setup":
223
+ return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
211
224
  case "connect": {
212
225
  const url = parsed.positionals[1];
213
226
  if (!url) {
@@ -280,6 +293,8 @@ export async function main(argv, io = {
280
293
  return await serverRunsCommand(system, io.out, colors);
281
294
  return await logsCommand(system, [...rest, ...(values.follow ? ["-f"] : [])]);
282
295
  }
296
+ case "local":
297
+ return await localCommand(parsed.positionals.slice(1), { purge: Boolean(values.purge) }, io, colors, ui);
283
298
  case "uninstall":
284
299
  await uninstall(system, { purge: Boolean(values.purge) });
285
300
  return 0;
@@ -309,7 +324,10 @@ const NOT_A_SERVER = (c) => `
309
324
  Background coding agents on your own server: send a task from your laptop,
310
325
  get a pull request back.
311
326
 
312
- ${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):")}
313
331
  curl -fsSL https://directionallylocal.run | sh
314
332
 
315
333
  ${c.dim("Already have one? Connect this laptop to it:")}
@@ -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
+ }