directionallylocal 0.3.0 → 0.4.1

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.
@@ -1,23 +1,30 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { hostname } from "node:os";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir, hostname } from "node:os";
4
5
  import { promisify } from "node:util";
6
+ import { isModelNotPriced } from "../model-prices.js";
5
7
  import { palette } from "../ui.js";
6
8
  import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
7
- import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
9
+ import { defaultRemoteName, loadRemotes, loadRemotesFile, normalizeServerUrl, recordLastUsed, REMOTE_NAME_RE, removeRemote, renameRemote, resolveRemote, resolveTarget, saveRemote, setDefaultRemote, } from "./config.js";
10
+ import { autopilotConfigPath } from "./credentials.js";
11
+ import { PAIRED_NAME } from "../local/layout.js";
8
12
  import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
9
13
  import { sshCommandLine } from "./ssh.js";
10
14
  import { openBrowser } from "./open-browser.js";
11
15
  import { withConnections } from "./tunnel.js";
12
16
  /**
13
- * `dl` on a laptop: connect to a server, send it tasks, follow them, and check out the
14
- * pull requests. It talks to the server's API directly (the same one Autopilot's
15
- * `--on` uses, and shares its list of servers), so the laptop needs nothing else.
17
+ * `dl` on a laptop: send tasks to where agents run (this computer, or your own server),
18
+ * follow them, and check out the pull requests. It talks to the server's API directly (the
19
+ * same one Autopilot's `--on` uses, and shares its list of servers), so the laptop needs
20
+ * nothing else.
16
21
  * Servers set up with `dl setup` are reached through an SSH tunnel that each command
17
22
  * opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
18
23
  */
19
24
  const execFileAsync = promisify(execFile);
20
25
  const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
26
+ /** The model a task gets when neither -m nor the laptop's Autopilot picks one the server offers. */
27
+ export const DEFAULT_MODEL = "moonshotai/kimi-k2.6";
21
28
  const processIO = {
22
29
  out: (text) => process.stdout.write(text),
23
30
  err: (text) => process.stderr.write(text),
@@ -60,10 +67,11 @@ export async function connect(input, io = processIO) {
60
67
  * checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
61
68
  */
62
69
  export async function run(input, io = processIO) {
63
- const remote = await resolveRemote(input.server);
64
- return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, input, io));
70
+ const { remote, reason } = await resolveTarget(input.server);
71
+ const preferred = input.preferredModel ?? await (input.autopilotModel ?? (() => readAutopilotModel()))();
72
+ return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, reason, { ...input, ...(preferred ? { preferredModel: preferred } : {}) }, io));
65
73
  }
66
- async function runOn(client, remote, input, io) {
74
+ async function runOn(client, remote, reason, input, io) {
67
75
  const c = palette(io.color === true);
68
76
  const repository = input.repository ?? await detectGitHubRepository(input.cwd);
69
77
  const snapshot = await snapshotWorkingTree(input.cwd);
@@ -73,7 +81,8 @@ async function runOn(client, remote, input, io) {
73
81
  throw new Error(`${remote.name} has no workspace this laptop can use`);
74
82
  const model = pickModel(models, input.model, input.preferredModel);
75
83
  const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
76
- io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
84
+ const why = reason === "default" || reason === "last used" ? ` ${c.dim(`(${reason})`)}` : "";
85
+ io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}${why}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
77
86
  const conversation = await client.createCodeConversation({
78
87
  workspaceId: workspace.id,
79
88
  model,
@@ -83,6 +92,8 @@ async function runOn(client, remote, input, io) {
83
92
  idempotencyKey: `dl-${randomUUID()}`,
84
93
  });
85
94
  await client.submitTurn(conversation.conversationId, input.task, `dl-${randomUUID()}`);
95
+ // Only a fallback (a default, or the only place, always wins), so failing to write it is harmless.
96
+ await recordLastUsed(remote.name).catch(() => undefined);
86
97
  const id = conversation.conversationId.slice(0, 8);
87
98
  io.err(`${rewrite(io)} ${c.green("✓")} Started ${c.bold(id)} on ${remote.name} ${c.dim(`branch ${conversation.branch ?? "?"}`)}\n`);
88
99
  if (input.detach) {
@@ -96,7 +107,7 @@ async function runOn(client, remote, input, io) {
96
107
  export async function runs(input, io = processIO) {
97
108
  const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
98
109
  if (remotes.length === 0)
99
- throw new Error("This laptop isn't connected to a server yet. Run: dl connect https://your-server");
110
+ throw new Error("Nothing is set up yet. Run: dl setup (or, for a server someone else set up: dl connect https://your-server)");
100
111
  return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
101
112
  }
102
113
  async function listRuns(remotes, connections, json, io) {
@@ -173,9 +184,11 @@ export async function pull(input, io = processIO) {
173
184
  }
174
185
  io.out(` ${c.green("✓")} On ${c.bold(branch)}${found.pullRequestURL ? ` ${c.dim(found.pullRequestURL)}` : ""}\n`);
175
186
  }
176
- /** `dl servers [remove NAME]`: the servers this laptop is connected to. */
187
+ /** `dl servers [remove NAME | rename OLD NEW]`: the servers this laptop is connected to. */
177
188
  export async function servers(args, io = processIO) {
178
189
  const c = palette(io.color === true);
190
+ if (args[0] === "rename")
191
+ return renameServer(args.slice(1), io);
179
192
  if (args[0] === "remove") {
180
193
  const name = args[1] ?? "";
181
194
  const removed = (await loadRemotes()).find((remote) => remote.name === name);
@@ -189,13 +202,65 @@ export async function servers(args, io = processIO) {
189
202
  }
190
203
  const remotes = await loadRemotes();
191
204
  if (remotes.length === 0) {
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`);
205
+ io.out(`\n Nothing set up yet. ${c.bold("dl setup")} ${c.dim("or, for a server someone else set up:")} ${c.bold("dl connect https://your-server")}\n\n`);
193
206
  return 0;
194
207
  }
208
+ const current = await defaultName();
195
209
  io.out("\n");
196
210
  for (const remote of remotes)
197
- io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
198
- io.out("\n");
211
+ io.out(` ${mark(remote, current, c)} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
212
+ io.out(remotes.length > 1 ? `\n ${c.dim(`● is where tasks go without --server. Change it: dl use NAME`)}\n\n` : "\n");
213
+ return 0;
214
+ }
215
+ /** `dl use [NAME]`: set, or show, where commands go when no --server is given. */
216
+ export async function use(args, io = processIO) {
217
+ const c = palette(io.color === true);
218
+ const name = args[0];
219
+ if (name === undefined) {
220
+ const file = await loadRemotesFile();
221
+ if (file.remotes.length === 0) {
222
+ io.out(` Nothing set up yet. ${c.bold("dl setup")}\n`);
223
+ return 0;
224
+ }
225
+ const current = await defaultName();
226
+ io.out(current
227
+ ? ` Tasks go to ${c.bold(current)} unless you pass --server NAME.${file.remotes.length > 1 ? ` ${c.dim(`Change it: dl use NAME (${file.remotes.map((entry) => entry.name).join(", ")})`)}` : ""}\n`
228
+ : ` No default yet. Pick one: dl use NAME ${c.dim(`(${file.remotes.map((entry) => entry.name).join(", ")})`)}\n`);
229
+ return 0;
230
+ }
231
+ try {
232
+ const remote = await setDefaultRemote(name);
233
+ io.out(` ${c.green("✓")} Tasks now go to ${c.bold(remote.name)} ${c.dim(where(remote))} unless you pass --server NAME.\n`);
234
+ return 0;
235
+ }
236
+ catch (error) {
237
+ io.err(` ${c.red("✗")} ${describeError(error)}\n`);
238
+ return 1;
239
+ }
240
+ }
241
+ /** Where a command without --server goes, or undefined when that's ambiguous. */
242
+ async function defaultName() {
243
+ return resolveTarget(undefined).then((target) => target.remote.name, () => undefined);
244
+ }
245
+ function mark(remote, current, c) {
246
+ return remote.name === current ? c.green("●") : c.dim("○");
247
+ }
248
+ /** `dl servers rename OLD NEW`: only this laptop's name for it; the server doesn't change. */
249
+ export async function renameServer(args, io = processIO, path) {
250
+ const c = palette(io.color === true);
251
+ const [from, to] = args;
252
+ if (!from || !to || args.length > 2) {
253
+ io.err(` Usage: dl servers rename OLD NEW ${c.dim("(names: lowercase letters, digits, and dashes)")}\n`);
254
+ return 2;
255
+ }
256
+ try {
257
+ await renameRemote(from, to, path);
258
+ }
259
+ catch (error) {
260
+ io.err(` ${c.red("✗")} ${error.message}.\n`.replace(/\.\.\n$/, ".\n"));
261
+ return 1;
262
+ }
263
+ io.out(` ${c.green("✓")} Renamed ${from} to ${c.bold(to)} ${c.dim(`dl run --server ${to} …`)}\n`);
199
264
  return 0;
200
265
  }
201
266
  /** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
@@ -213,9 +278,10 @@ export async function laptopHome(io = processIO, open) {
213
278
  total += list?.length ?? 0;
214
279
  }
215
280
  });
281
+ const current = await defaultName();
216
282
  io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
217
- io.out(" Background coding agents on your own server: send a task, get a pull request back.\n\n");
218
- io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim("dl servers")}\n`);
283
+ io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
284
+ io.out(` ${c.dim("Runs on".padEnd(11))}${remotes.map((remote) => `${mark(remote, current, c)} ${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim(remotes.length > 1 ? "dl servers · dl use NAME" : "dl servers")}\n`);
219
285
  io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
220
286
  io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
221
287
  return true;
@@ -258,7 +324,11 @@ async function follow(client, remote, conversationId, cursor, signal, io, replay
258
324
  io.err("\n");
259
325
  return 0;
260
326
  }
261
- io.err(`\n ${c.red("✗")} ${id} ${outcome}\n\n`);
327
+ io.err(`\n ${c.red("✗")} ${id} ${outcome}\n`);
328
+ if (renderer.unpriced || isModelNotPriced(JSON.stringify(final))) {
329
+ io.err(` This model isn't in your server's price list yet. Run ${c.bold("dl setup")} to refresh it, or pick another model with -m.\n`);
330
+ }
331
+ io.err("\n");
262
332
  return 1;
263
333
  }
264
334
  /** Writes the agent's text as it streams and one line per tool call. */
@@ -266,12 +336,16 @@ class EventRenderer {
266
336
  io;
267
337
  c;
268
338
  midLine = false;
339
+ /** Hotcell refused the model for lack of a price (a stale price list on the server). */
340
+ unpriced = false;
269
341
  constructor(io, c) {
270
342
  this.io = io;
271
343
  this.c = c;
272
344
  }
273
345
  render(event) {
274
346
  const data = event.data;
347
+ if (event.type !== "assistant.delta" && !this.unpriced && isModelNotPriced(JSON.stringify(data)))
348
+ this.unpriced = true;
275
349
  if (event.type === "assistant.delta") {
276
350
  const text = typeof data.text === "string" ? data.text : typeof data.delta === "string" ? data.delta : "";
277
351
  if (!text)
@@ -326,22 +400,53 @@ async function findRun(id, server, connections) {
326
400
  throw new Error(`${id} matches several tasks; use more characters`);
327
401
  return matches[0];
328
402
  }
329
- function pickModel(models, requested, preferred) {
403
+ /**
404
+ * The task's model: -m/--model; else the laptop's Autopilot default, if the server offers it;
405
+ * else DEFAULT_MODEL, if offered. Never just the first in the server's list (that's the
406
+ * whole OpenRouter catalog, in no useful order).
407
+ */
408
+ export function pickModel(models, requested, preferred) {
409
+ const how = `Pick one: dl run -m <model> "task" (model IDs: https://openrouter.ai/models)`;
330
410
  if (requested) {
331
411
  if (!models.includes(requested))
332
- throw new Error(`The server doesn't offer ${requested}`);
412
+ throw new Error(`The server doesn't offer ${requested}. ${how}`);
333
413
  return requested;
334
414
  }
335
415
  if (preferred && models.includes(preferred))
336
416
  return preferred;
337
- const first = models[0];
338
- if (!first)
339
- throw new Error("The server offers no models");
340
- return first;
417
+ if (models.includes(DEFAULT_MODEL))
418
+ return DEFAULT_MODEL;
419
+ if (models.length === 0)
420
+ throw new Error("The server offers no models; check its OpenRouter key (on the server: dl doctor)");
421
+ const tried = preferred && preferred !== DEFAULT_MODEL ? `your Autopilot model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
422
+ throw new Error(`The server doesn't offer ${tried}. ${how}`);
423
+ }
424
+ /**
425
+ * The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
426
+ * `model` in its config. undefined when neither is set or the config can't be read.
427
+ */
428
+ export async function readAutopilotModel(sources = {}) {
429
+ const env = sources.env ?? process.env;
430
+ const fromEnv = env.KIMI_MODEL?.trim();
431
+ if (fromEnv)
432
+ return fromEnv;
433
+ const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
434
+ const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
435
+ if (!raw)
436
+ return undefined;
437
+ try {
438
+ const model = JSON.parse(raw).model;
439
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
440
+ }
441
+ catch {
442
+ return undefined;
443
+ }
341
444
  }
342
- /** Where a server is, as people know it: its URL, or the SSH destination its tunnel goes to. */
445
+ /** Where a place is, as people know it: this computer, its URL, or the SSH destination its tunnel goes to. */
343
446
  function where(remote) {
344
- return remote.ssh ? `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}` : remote.url;
447
+ if (remote.ssh)
448
+ return `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
449
+ return remote.name === PAIRED_NAME ? "this computer" : remote.url;
345
450
  }
346
451
  function clientName() {
347
452
  const host = hostname().replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
@@ -9,47 +9,127 @@ export function remotesPath(env = process.env) {
9
9
  return join(configHome, "autopilot", "remotes.json");
10
10
  }
11
11
  export async function loadRemotes(path = remotesPath()) {
12
+ return (await loadRemotesFile(path)).remotes;
13
+ }
14
+ /**
15
+ * The remotes with the default and last-used names. A name that no longer matches a
16
+ * remote (it was removed, or renamed by something that didn't know) is dropped.
17
+ */
18
+ export async function loadRemotesFile(path = remotesPath()) {
12
19
  let raw;
13
20
  try {
14
21
  raw = await readFile(path, "utf8");
15
22
  }
16
23
  catch (error) {
17
24
  if (error.code === "ENOENT")
18
- return [];
25
+ return { remotes: [] };
19
26
  throw error;
20
27
  }
21
28
  const parsed = JSON.parse(raw);
22
- return Array.isArray(parsed.remotes) ? parsed.remotes.filter(isRemote) : [];
29
+ const remotes = Array.isArray(parsed.remotes) ? parsed.remotes.filter(isRemote) : [];
30
+ const known = (name) => typeof name === "string" && remotes.some((entry) => entry.name === name);
31
+ return {
32
+ remotes,
33
+ ...(known(parsed.default) ? { default: parsed.default } : {}),
34
+ ...(known(parsed.lastUsed) ? { lastUsed: parsed.lastUsed } : {}),
35
+ };
23
36
  }
24
- /** Adds or replaces a remote by name. The file holds bearer tokens, so it is 0600 in a 0700 directory. */
37
+ /**
38
+ * Adds or replaces a remote by name. The file holds bearer tokens, so it is 0600 in a 0700
39
+ * directory. When no default is set yet, the first place saved becomes it (an older file's
40
+ * first entry, if it had any), so the first setup needs no `dl use`.
41
+ */
25
42
  export async function saveRemote(remote, path = remotesPath()) {
26
- const remotes = (await loadRemotes(path)).filter((entry) => entry.name !== remote.name);
43
+ const file = await loadRemotesFile(path);
44
+ const remotes = file.remotes.filter((entry) => entry.name !== remote.name);
27
45
  remotes.push(remote);
28
- await writeRemotes(remotes, path);
46
+ await writeRemotes({ ...file, remotes, default: file.default ?? file.remotes[0]?.name ?? remote.name }, path);
29
47
  }
30
48
  export async function removeRemote(name, path = remotesPath()) {
31
- const remotes = await loadRemotes(path);
32
- const kept = remotes.filter((entry) => entry.name !== name);
33
- if (kept.length === remotes.length)
49
+ const file = await loadRemotesFile(path);
50
+ const kept = file.remotes.filter((entry) => entry.name !== name);
51
+ if (kept.length === file.remotes.length)
34
52
  return false;
35
- await writeRemotes(kept, path);
53
+ // Forgetting the default leaves none; with one place left, that one is used anyway.
54
+ await writeRemotes({
55
+ remotes: kept,
56
+ ...(file.default && file.default !== name ? { default: file.default } : {}),
57
+ ...(file.lastUsed && file.lastUsed !== name ? { lastUsed: file.lastUsed } : {}),
58
+ }, path);
36
59
  return true;
37
60
  }
38
- /** The named remote, or the only one when no name is given. */
39
- export async function resolveRemote(name, path = remotesPath()) {
40
- const remotes = await loadRemotes(path);
41
- if (remotes.length === 0)
42
- throw new Error("No servers paired yet. Run: autopilot remote add https://your-server");
43
- if (name === undefined) {
44
- if (remotes.length === 1)
45
- return remotes[0];
46
- throw new Error(`Several servers are paired; pick one with --on (${remotes.map((entry) => entry.name).join(", ")})`);
61
+ /**
62
+ * `dl servers rename OLD NEW`: renames a place in this laptop's list (the server itself doesn't
63
+ * change). The `default` and `lastUsed` pointers follow the entry to its new name.
64
+ */
65
+ export async function renameRemote(from, to, path = remotesPath()) {
66
+ if (!REMOTE_NAME_RE.test(to))
67
+ throw new Error(`"${to}" can't be a server name: use lowercase letters, digits, and dashes (up to 40), starting with a letter or digit`);
68
+ const file = await loadRemotesFile(path);
69
+ if (!file.remotes.some((entry) => entry.name === from)) {
70
+ throw new Error(`No server named "${from}". Connected: ${file.remotes.map((entry) => entry.name).join(", ") || "none"}`);
47
71
  }
48
- const remote = remotes.find((entry) => entry.name === name);
72
+ if (from === to)
73
+ return;
74
+ if (file.remotes.some((entry) => entry.name === to))
75
+ throw new Error(`There's already a server named "${to}"`);
76
+ const follow = (name) => (name === from ? to : name);
77
+ const defaultName = follow(file.default);
78
+ const lastUsed = follow(file.lastUsed);
79
+ await writeRemotes({
80
+ remotes: file.remotes.map((entry) => (entry.name === from ? { ...entry, name: to } : entry)),
81
+ ...(defaultName ? { default: defaultName } : {}),
82
+ ...(lastUsed ? { lastUsed } : {}),
83
+ }, path);
84
+ }
85
+ /** `dl use NAME`: where commands go when none is named. */
86
+ export async function setDefaultRemote(name, path = remotesPath()) {
87
+ const file = await loadRemotesFile(path);
88
+ const remote = file.remotes.find((entry) => entry.name === name);
49
89
  if (!remote)
50
- throw new Error(`No server named "${name}". Paired: ${remotes.map((entry) => entry.name).join(", ")}`);
90
+ throw new Error(unknownName(name, file.remotes));
91
+ await writeRemotes({ ...file, default: name }, path);
51
92
  return remote;
52
93
  }
94
+ /** Remembers where the last task went (the fallback when there's no default). */
95
+ export async function recordLastUsed(name, path = remotesPath()) {
96
+ const file = await loadRemotesFile(path);
97
+ if (file.lastUsed === name || !file.remotes.some((entry) => entry.name === name))
98
+ return;
99
+ await writeRemotes({ ...file, lastUsed: name }, path);
100
+ }
101
+ /** The named remote, else the default, else the only one, else the last one used. */
102
+ export async function resolveRemote(name, path = remotesPath()) {
103
+ return (await resolveTarget(name, path)).remote;
104
+ }
105
+ /** resolveRemote, and why that one (for the command's first line). */
106
+ export async function resolveTarget(name, path = remotesPath()) {
107
+ const file = await loadRemotesFile(path);
108
+ const { remotes } = file;
109
+ if (remotes.length === 0)
110
+ throw new Error("Nothing is set up yet. Run: dl setup");
111
+ const named = (wanted) => remotes.find((entry) => entry.name === wanted);
112
+ if (name !== undefined) {
113
+ const remote = named(name);
114
+ if (!remote)
115
+ throw new Error(unknownName(name, remotes));
116
+ return { remote, reason: "named" };
117
+ }
118
+ if (remotes.length === 1)
119
+ return { remote: remotes[0], reason: "only" };
120
+ const chosen = file.default ? named(file.default) : undefined;
121
+ if (chosen)
122
+ return { remote: chosen, reason: "default" };
123
+ const last = file.lastUsed ? named(file.lastUsed) : undefined;
124
+ if (last)
125
+ return { remote: last, reason: "last used" };
126
+ throw new Error(`Several places are set up (${remotes.map((entry) => entry.name).join(", ")}). Pick one with --server NAME, or set a default: dl use NAME`);
127
+ }
128
+ function unknownName(name, remotes) {
129
+ return remotes.length
130
+ ? `Nothing named "${name}" is set up. Set up: ${remotes.map((entry) => entry.name).join(", ")}`
131
+ : `Nothing named "${name}" is set up; nothing is yet. Run: dl setup`;
132
+ }
53
133
  /** A short default name from the URL's host: https://acme.example.com -> acme. */
54
134
  export function defaultRemoteName(url) {
55
135
  const host = new URL(url).hostname.toLowerCase();
@@ -74,10 +154,16 @@ export function normalizeServerUrl(input) {
74
154
  throw new Error("The server URL must not include credentials, a query, or a fragment");
75
155
  return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
76
156
  }
77
- async function writeRemotes(remotes, path) {
157
+ async function writeRemotes(file, path) {
78
158
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
79
159
  const temp = `${path}.${process.pid}.tmp`;
80
- await writeFile(temp, `${JSON.stringify({ version: 1, remotes }, null, 2)}\n`, { mode: 0o600 });
160
+ const body = {
161
+ version: 1,
162
+ remotes: file.remotes,
163
+ ...(file.default ? { default: file.default } : {}),
164
+ ...(file.lastUsed ? { lastUsed: file.lastUsed } : {}),
165
+ };
166
+ await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
81
167
  await chmod(temp, 0o600);
82
168
  await rename(temp, path);
83
169
  }
@@ -52,7 +52,7 @@ export async function gatherKeys(needs, input) {
52
52
  answers.openrouterKey = await openrouterKey(input);
53
53
  return answers;
54
54
  }
55
- async function githubToken({ ui, prompter, sources }) {
55
+ export async function githubToken({ ui, prompter, sources }) {
56
56
  const fromCli = (await sources.gh().catch(() => undefined))?.trim();
57
57
  if (fromCli) {
58
58
  const checked = await validateGitHubToken(fromCli, sources.fetch);
@@ -73,7 +73,7 @@ async function githubToken({ ui, prompter, sources }) {
73
73
  return { value: token, ...(checked.problem ? { problem: checked.problem } : {}), ...(checked.login ? { detail: `signed in as ${checked.login}` } : {}) };
74
74
  });
75
75
  }
76
- async function openrouterKey({ ui, prompter, sources }) {
76
+ export async function openrouterKey({ ui, prompter, sources }) {
77
77
  const candidates = [];
78
78
  const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
79
79
  if (fromEnv)
@@ -0,0 +1,133 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { promisify } from "node:util";
5
+ import { KEY_LABELS, formatKeyListing, parseKeyName } from "../keys.js";
6
+ import { listLocalKeys, setLocalKey } from "../local/keys.js";
7
+ import { PAIRED_NAME } from "../local/layout.js";
8
+ import { TtyPrompter } from "../prompt.js";
9
+ import { palette, PlainUi, TerminalUi } from "../ui.js";
10
+ import { resolveRemote } from "./config.js";
11
+ import { githubToken, openrouterKey } from "./credentials.js";
12
+ import { checkServer, realSshExec } from "./ssh.js";
13
+ export const LAPTOP_KEYS_USAGE = " Usage: dl keys [openrouter|github] [--server NAME]\n"
14
+ + " dl keys which keys are set where your agents run\n"
15
+ + " dl keys openrouter replace the OpenRouter key dl keys github replace the GitHub token\n";
16
+ export async function laptopKeys(args, options, deps) {
17
+ const [first, ...rest] = args;
18
+ const which = first === undefined || first === "ls" || first === "list" ? undefined : parseKeyName(first);
19
+ if ((first !== undefined && first !== "ls" && first !== "list" && !which) || rest.length > 0) {
20
+ deps.write(LAPTOP_KEYS_USAGE);
21
+ return 2;
22
+ }
23
+ const target = await resolveTarget(options.server, deps.remotesFile);
24
+ const again = (key) => `dl keys ${key}${options.server ? ` --server ${target.remote.name}` : ""}`;
25
+ if (!which) {
26
+ const listing = target.kind === "local" ? await deps.local.list() : await listOverSsh(target, deps.ssh);
27
+ deps.write(formatKeyListing(listing, palette(deps.color), `on ${target.kind === "local" ? "this computer" : target.remote.name}`, again));
28
+ return 0;
29
+ }
30
+ // Reach the server before asking for anything, so a dead connection doesn't waste a key.
31
+ const root = target.kind === "ssh" ? (await checkServer(target.access, deps.ssh, target.remote.name)).root : true;
32
+ const where = target.kind === "local" ? "this computer" : target.remote.name;
33
+ const label = `${KEY_LABELS[which]} ${which === "github" ? "token" : "key"}`;
34
+ const askUi = deps.newUi();
35
+ askUi.questions(`A new ${label} for ${where}; it replaces the one there. Typing is hidden.`);
36
+ const value = which === "github"
37
+ ? await githubToken({ ui: askUi, prompter: deps.prompter, sources: deps.sources })
38
+ : await openrouterKey({ ui: askUi, prompter: deps.prompter, sources: deps.sources });
39
+ askUi.finish();
40
+ const ui = deps.newUi();
41
+ ui.plan([{ id: "apply", label: `Replacing the ${label} on ${where}` }]);
42
+ ui.start("apply");
43
+ try {
44
+ const result = target.kind === "local" ? await deps.local.set(which, value) : await setOverSsh(target, root, which, value, deps.ssh);
45
+ ui.done("apply", result.detail);
46
+ }
47
+ catch (error) {
48
+ ui.fail("apply", error instanceof Error ? error.message.split("\n")[0] : String(error));
49
+ ui.finish();
50
+ throw error;
51
+ }
52
+ ui.finish();
53
+ return 0;
54
+ }
55
+ /** The server in use: `--server NAME`, or the only (or default) one. */
56
+ async function resolveTarget(server, remotesFile) {
57
+ const remote = remotesFile ? await resolveRemote(server, remotesFile) : await resolveRemote(server);
58
+ if (remote.ssh)
59
+ return { kind: "ssh", remote, access: remote.ssh };
60
+ if (remote.name === PAIRED_NAME)
61
+ return { kind: "local", remote };
62
+ throw new Error(`${remote.name} was connected with dl connect, so this laptop can't reach its keys. On that server, run: sudo dl keys`);
63
+ }
64
+ function sudo(root) {
65
+ return root ? "" : "sudo -n ";
66
+ }
67
+ async function setOverSsh(target, root, which, value, ssh) {
68
+ // The key travels on stdin only; the command line names which key, never its value.
69
+ const result = await ssh(target.access, `${sudo(root)}dl keys set ${which} --stdin --json`, { input: `${value}\n` });
70
+ const parsed = lastJson(result.stdout);
71
+ if (parsed?.ok === true)
72
+ return { ok: true, key: which, detail: typeof parsed.detail === "string" ? parsed.detail : "replaced" };
73
+ if (typeof parsed?.error === "string")
74
+ throw new Error(`${target.remote.name}: ${parsed.error}`);
75
+ throw serverFailure(target.remote.name, result);
76
+ }
77
+ async function listOverSsh(target, ssh) {
78
+ const { root } = await checkServer(target.access, ssh, target.remote.name);
79
+ const result = await ssh(target.access, `${sudo(root)}dl keys --json`);
80
+ const parsed = lastJson(result.stdout);
81
+ if (result.code !== 0 || !parsed || !Array.isArray(parsed.keys))
82
+ throw serverFailure(target.remote.name, result);
83
+ return parsed.keys.filter(isListing);
84
+ }
85
+ function serverFailure(name, result) {
86
+ if (/Unknown command "keys"|Unknown option '--(stdin|json)'/.test(result.stderr)) {
87
+ return new Error(`The dl on ${name} is too old for dl keys; update it with dl setup (it keeps your keys), then try again.`);
88
+ }
89
+ return new Error(`Couldn't change the keys on ${name}: ${lastLine(result.stderr) || lastLine(result.stdout) || `exit ${result.code}`}`);
90
+ }
91
+ function isListing(value) {
92
+ if (!value || typeof value !== "object")
93
+ return false;
94
+ const entry = value;
95
+ return (entry.key === "openrouter" || entry.key === "github") && typeof entry.set === "boolean"
96
+ && (entry.detail === undefined || typeof entry.detail === "string") && (entry.problem === undefined || typeof entry.problem === "string");
97
+ }
98
+ function lastJson(stdout) {
99
+ for (const line of stdout.trim().split("\n").reverse()) {
100
+ try {
101
+ return JSON.parse(line);
102
+ }
103
+ catch {
104
+ // not JSON; keep looking
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+ function lastLine(text) {
110
+ return text.trim().split("\n").filter((line) => line.trim() && !/Permanently added/i.test(line)).at(-1)?.trim() ?? "";
111
+ }
112
+ /** `dl keys` on a laptop with the real terminal, ssh, gh, and filesystem. */
113
+ export async function keys(args, options) {
114
+ const { io, terminal } = options;
115
+ const color = io.color === true;
116
+ const execFileAsync = promisify(execFile);
117
+ return laptopKeys(args, options.server ? { server: options.server } : {}, {
118
+ newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
119
+ prompter: new TtyPrompter(),
120
+ write: io.out,
121
+ color,
122
+ ssh: realSshExec,
123
+ sources: {
124
+ env: process.env,
125
+ home: homedir(),
126
+ readFile: (path) => readFile(path, "utf8").catch(() => undefined),
127
+ gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
128
+ fetch: globalThis.fetch,
129
+ },
130
+ // Checked online here already; this computer re-checks only the format.
131
+ local: { set: (which, value) => setLocalKey(which, value), list: () => listLocalKeys({ fetchImpl: globalThis.fetch }) },
132
+ });
133
+ }