directionallylocal 0.2.0 → 0.4.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.
@@ -1,19 +1,29 @@
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";
5
6
  import { palette } from "../ui.js";
6
7
  import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
7
- import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
8
+ import { defaultRemoteName, loadRemotes, loadRemotesFile, normalizeServerUrl, recordLastUsed, REMOTE_NAME_RE, removeRemote, renameRemote, resolveRemote, resolveTarget, saveRemote, setDefaultRemote, } from "./config.js";
9
+ import { autopilotConfigPath } from "./credentials.js";
10
+ import { PAIRED_NAME } from "../local/layout.js";
8
11
  import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
12
+ import { sshCommandLine } from "./ssh.js";
9
13
  import { openBrowser } from "./open-browser.js";
14
+ import { withConnections } from "./tunnel.js";
10
15
  /**
11
- * `dl` on a laptop: connect to a server, send it tasks, follow them, and check out the
12
- * pull requests. It talks to the server's API directly (the same one Autopilot's
13
- * `--on` uses, and shares its list of servers), so the laptop needs nothing else.
16
+ * `dl` on a laptop: send tasks to where agents run (this computer, or your own server),
17
+ * follow them, and check out the pull requests. It talks to the server's API directly (the
18
+ * same one Autopilot's `--on` uses, and shares its list of servers), so the laptop needs
19
+ * nothing else.
20
+ * Servers set up with `dl setup` are reached through an SSH tunnel that each command
21
+ * opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
14
22
  */
15
23
  const execFileAsync = promisify(execFile);
16
24
  const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
25
+ /** The model a task gets when neither -m nor the laptop's Autopilot picks one the server offers. */
26
+ export const DEFAULT_MODEL = "moonshotai/kimi-k2.6";
17
27
  const processIO = {
18
28
  out: (text) => process.stdout.write(text),
19
29
  err: (text) => process.stderr.write(text),
@@ -56,9 +66,12 @@ export async function connect(input, io = processIO) {
56
66
  * checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
57
67
  */
58
68
  export async function run(input, io = processIO) {
69
+ const { remote, reason } = await resolveTarget(input.server);
70
+ const preferred = input.preferredModel ?? await (input.autopilotModel ?? (() => readAutopilotModel()))();
71
+ return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, reason, { ...input, ...(preferred ? { preferredModel: preferred } : {}) }, io));
72
+ }
73
+ async function runOn(client, remote, reason, input, io) {
59
74
  const c = palette(io.color === true);
60
- const remote = await resolveRemote(input.server);
61
- const client = new RemoteClient(remote.url, remote.token);
62
75
  const repository = input.repository ?? await detectGitHubRepository(input.cwd);
63
76
  const snapshot = await snapshotWorkingTree(input.cwd);
64
77
  const [workspaces, models] = await Promise.all([client.workspaces(), client.models()]);
@@ -67,7 +80,8 @@ export async function run(input, io = processIO) {
67
80
  throw new Error(`${remote.name} has no workspace this laptop can use`);
68
81
  const model = pickModel(models, input.model, input.preferredModel);
69
82
  const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
70
- io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
83
+ const why = reason === "default" || reason === "last used" ? ` ${c.dim(`(${reason})`)}` : "";
84
+ io.err(`\n ${c.cyan("·")} Sending to ${c.bold(remote.name)}${why}: ${repository} at ${snapshot.baseCommit.slice(0, 7)}${changes} ${c.dim(model)}\n`);
71
85
  const conversation = await client.createCodeConversation({
72
86
  workspaceId: workspace.id,
73
87
  model,
@@ -77,6 +91,8 @@ export async function run(input, io = processIO) {
77
91
  idempotencyKey: `dl-${randomUUID()}`,
78
92
  });
79
93
  await client.submitTurn(conversation.conversationId, input.task, `dl-${randomUUID()}`);
94
+ // Only a fallback (a default, or the only place, always wins), so failing to write it is harmless.
95
+ await recordLastUsed(remote.name).catch(() => undefined);
80
96
  const id = conversation.conversationId.slice(0, 8);
81
97
  io.err(`${rewrite(io)} ${c.green("✓")} Started ${c.bold(id)} on ${remote.name} ${c.dim(`branch ${conversation.branch ?? "?"}`)}\n`);
82
98
  if (input.detach) {
@@ -88,14 +104,17 @@ export async function run(input, io = processIO) {
88
104
  }
89
105
  /** `dl runs`: tasks on every connected server, newest first. */
90
106
  export async function runs(input, io = processIO) {
91
- const c = palette(io.color === true);
92
107
  const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
93
108
  if (remotes.length === 0)
94
- throw new Error("This laptop isn't connected to a server yet. Run: dl connect https://your-server");
109
+ throw new Error("Nothing is set up yet. Run: dl setup (or, for a server someone else set up: dl connect https://your-server)");
110
+ return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
111
+ }
112
+ async function listRuns(remotes, connections, json, io) {
113
+ const c = palette(io.color === true);
95
114
  const rows = [];
96
115
  for (const remote of remotes) {
97
116
  try {
98
- const list = await new RemoteClient(remote.url, remote.token).listConversations();
117
+ const list = await (await connections.client(remote)).listConversations();
99
118
  rows.push(...list.filter((entry) => entry.kind === "code").map((entry) => ({ ...entry, server: remote.name })));
100
119
  }
101
120
  catch (error) {
@@ -103,7 +122,7 @@ export async function runs(input, io = processIO) {
103
122
  }
104
123
  }
105
124
  rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
106
- if (input.json) {
125
+ if (json) {
107
126
  io.out(`${JSON.stringify(rows, null, 2)}\n`);
108
127
  return;
109
128
  }
@@ -122,21 +141,26 @@ export async function runs(input, io = processIO) {
122
141
  }
123
142
  /** `dl watch <id>`: replay and follow a task's output. */
124
143
  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);
144
+ return withConnections(input.open, async (connections) => {
145
+ const { remote, run: found } = await findRun(input.id, input.server, connections);
146
+ const client = await connections.client(remote);
147
+ const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
148
+ return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
149
+ });
129
150
  }
130
151
  export async function cancel(input, io = processIO) {
131
152
  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`);
153
+ await withConnections(input.open, async (connections) => {
154
+ const { remote, run: found } = await findRun(input.id, input.server, connections);
155
+ await (await connections.client(remote)).cancel(found.conversationId);
156
+ io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
157
+ });
135
158
  }
136
159
  /** `dl pull <id>`: check out a task's branch here. */
137
160
  export async function pull(input, io = processIO) {
138
161
  const c = palette(io.color === true);
139
- const { run: found } = await findRun(input.id, input.server);
162
+ // The server is only asked for the branch name; the code itself comes from GitHub.
163
+ const { run: found } = await withConnections(input.open, (connections) => findRun(input.id, input.server, connections));
140
164
  const branch = found.branch;
141
165
  if (!branch)
142
166
  throw new Error("That task has no branch");
@@ -159,45 +183,104 @@ export async function pull(input, io = processIO) {
159
183
  }
160
184
  io.out(` ${c.green("✓")} On ${c.bold(branch)}${found.pullRequestURL ? ` ${c.dim(found.pullRequestURL)}` : ""}\n`);
161
185
  }
162
- /** `dl servers [remove NAME]`: the servers this laptop is connected to. */
186
+ /** `dl servers [remove NAME | rename OLD NEW]`: the servers this laptop is connected to. */
163
187
  export async function servers(args, io = processIO) {
164
188
  const c = palette(io.color === true);
189
+ if (args[0] === "rename")
190
+ return renameServer(args.slice(1), io);
165
191
  if (args[0] === "remove") {
166
192
  const name = args[1] ?? "";
167
- if (!(await removeRemote(name))) {
193
+ const removed = (await loadRemotes()).find((remote) => remote.name === name);
194
+ if (!removed || !(await removeRemote(name))) {
168
195
  io.err(` ${c.red("✗")} Not connected to "${name}".\n`);
169
196
  return 1;
170
197
  }
171
- io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: sudo dl laptops remove "${clientName()}"\n`);
198
+ const onServer = `sudo dl laptops remove "${clientName()}"`;
199
+ io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: ${removed.ssh ? `${sshCommandLine(removed.ssh)} '${onServer}'` : onServer}\n`);
172
200
  return 0;
173
201
  }
174
202
  const remotes = await loadRemotes();
175
203
  if (remotes.length === 0) {
176
- io.out(`\n Not connected to a server yet. ${c.bold("dl connect https://your-server")}\n\n`);
204
+ 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`);
177
205
  return 0;
178
206
  }
207
+ const current = await defaultName();
179
208
  io.out("\n");
180
209
  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`);
182
- io.out("\n");
210
+ io.out(` ${mark(remote, current, c)} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
211
+ io.out(remotes.length > 1 ? `\n ${c.dim(`● is where tasks go without --server. Change it: dl use NAME`)}\n\n` : "\n");
212
+ return 0;
213
+ }
214
+ /** `dl use [NAME]`: set, or show, where commands go when no --server is given. */
215
+ export async function use(args, io = processIO) {
216
+ const c = palette(io.color === true);
217
+ const name = args[0];
218
+ if (name === undefined) {
219
+ const file = await loadRemotesFile();
220
+ if (file.remotes.length === 0) {
221
+ io.out(` Nothing set up yet. ${c.bold("dl setup")}\n`);
222
+ return 0;
223
+ }
224
+ const current = await defaultName();
225
+ io.out(current
226
+ ? ` 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`
227
+ : ` No default yet. Pick one: dl use NAME ${c.dim(`(${file.remotes.map((entry) => entry.name).join(", ")})`)}\n`);
228
+ return 0;
229
+ }
230
+ try {
231
+ const remote = await setDefaultRemote(name);
232
+ io.out(` ${c.green("✓")} Tasks now go to ${c.bold(remote.name)} ${c.dim(where(remote))} unless you pass --server NAME.\n`);
233
+ return 0;
234
+ }
235
+ catch (error) {
236
+ io.err(` ${c.red("✗")} ${describeError(error)}\n`);
237
+ return 1;
238
+ }
239
+ }
240
+ /** Where a command without --server goes, or undefined when that's ambiguous. */
241
+ async function defaultName() {
242
+ return resolveTarget(undefined).then((target) => target.remote.name, () => undefined);
243
+ }
244
+ function mark(remote, current, c) {
245
+ return remote.name === current ? c.green("●") : c.dim("○");
246
+ }
247
+ /** `dl servers rename OLD NEW`: only this laptop's name for it; the server doesn't change. */
248
+ export async function renameServer(args, io = processIO, path) {
249
+ const c = palette(io.color === true);
250
+ const [from, to] = args;
251
+ if (!from || !to || args.length > 2) {
252
+ io.err(` Usage: dl servers rename OLD NEW ${c.dim("(names: lowercase letters, digits, and dashes)")}\n`);
253
+ return 2;
254
+ }
255
+ try {
256
+ await renameRemote(from, to, path);
257
+ }
258
+ catch (error) {
259
+ io.err(` ${c.red("✗")} ${error.message}.\n`.replace(/\.\.\n$/, ".\n"));
260
+ return 1;
261
+ }
262
+ io.out(` ${c.green("✓")} Renamed ${from} to ${c.bold(to)} ${c.dim(`dl run --server ${to} …`)}\n`);
183
263
  return 0;
184
264
  }
185
265
  /** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
186
- export async function laptopHome(io = processIO) {
266
+ export async function laptopHome(io = processIO, open) {
187
267
  const c = palette(io.color === true);
188
268
  const remotes = await loadRemotes();
189
269
  if (remotes.length === 0)
190
270
  return false;
191
271
  let running = 0;
192
272
  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
- }
273
+ await withConnections(open, async (connections) => {
274
+ for (const remote of remotes) {
275
+ const list = await connections.client(remote).then((client) => client.listConversations()).catch(() => undefined);
276
+ running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
277
+ total += list?.length ?? 0;
278
+ }
279
+ });
280
+ const current = await defaultName();
198
281
  io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
199
- 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`);
282
+ io.out(" Coding agents that run on this computer or your own server: send a task, get a pull request back.\n\n");
283
+ 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`);
201
284
  io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
202
285
  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
286
  return true;
@@ -285,36 +368,76 @@ class EventRenderer {
285
368
  this.io.out(`${text}\n`);
286
369
  }
287
370
  }
288
- async function findRun(id, server) {
371
+ async function findRun(id, server, connections) {
289
372
  const needle = id.trim().toLowerCase();
290
373
  if (!/^[0-9a-f-]{4,36}$/.test(needle))
291
374
  throw new Error("Task IDs are the short hex IDs dl runs shows");
292
375
  const remotes = server ? [await resolveRemote(server)] : await loadRemotes();
293
376
  const matches = [];
377
+ const unreachable = [];
294
378
  for (const remote of remotes) {
295
- const list = await new RemoteClient(remote.url, remote.token).listConversations(200).catch(() => []);
379
+ const list = await connections.client(remote).then((client) => client.listConversations(200)).catch((error) => {
380
+ unreachable.push(`${remote.name}: ${describeError(error)}`);
381
+ return [];
382
+ });
296
383
  for (const entry of list)
297
384
  if (entry.conversationId.startsWith(needle))
298
385
  matches.push({ remote, run: entry });
299
386
  }
387
+ // Say why when a server couldn't be asked (e.g. its SSH tunnel failed), rather than "no task".
300
388
  if (matches.length === 0)
301
- throw new Error(`No task ${id} on ${server ?? "any connected server"}`);
389
+ throw new Error(`No task ${id} on ${server ?? "any connected server"}${unreachable.length ? `\n${unreachable.join("\n")}` : ""}`);
302
390
  if (matches.length > 1)
303
391
  throw new Error(`${id} matches several tasks; use more characters`);
304
392
  return matches[0];
305
393
  }
306
- function pickModel(models, requested, preferred) {
394
+ /**
395
+ * The task's model: -m/--model; else the laptop's Autopilot default, if the server offers it;
396
+ * else DEFAULT_MODEL, if offered. Never just the first in the server's list (that's the
397
+ * whole OpenRouter catalog, in no useful order).
398
+ */
399
+ export function pickModel(models, requested, preferred) {
400
+ const how = `Pick one: dl run -m <model> "task" (model IDs: https://openrouter.ai/models)`;
307
401
  if (requested) {
308
402
  if (!models.includes(requested))
309
- throw new Error(`The server doesn't offer ${requested}`);
403
+ throw new Error(`The server doesn't offer ${requested}. ${how}`);
310
404
  return requested;
311
405
  }
312
406
  if (preferred && models.includes(preferred))
313
407
  return preferred;
314
- const first = models[0];
315
- if (!first)
316
- throw new Error("The server offers no models");
317
- return first;
408
+ if (models.includes(DEFAULT_MODEL))
409
+ return DEFAULT_MODEL;
410
+ if (models.length === 0)
411
+ throw new Error("The server offers no models; check its OpenRouter key (on the server: dl doctor)");
412
+ const tried = preferred && preferred !== DEFAULT_MODEL ? `your Autopilot model (${preferred}) or ${DEFAULT_MODEL}` : DEFAULT_MODEL;
413
+ throw new Error(`The server doesn't offer ${tried}. ${how}`);
414
+ }
415
+ /**
416
+ * The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
417
+ * `model` in its config. undefined when neither is set or the config can't be read.
418
+ */
419
+ export async function readAutopilotModel(sources = {}) {
420
+ const env = sources.env ?? process.env;
421
+ const fromEnv = env.KIMI_MODEL?.trim();
422
+ if (fromEnv)
423
+ return fromEnv;
424
+ const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
425
+ const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
426
+ if (!raw)
427
+ return undefined;
428
+ try {
429
+ const model = JSON.parse(raw).model;
430
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
431
+ }
432
+ catch {
433
+ return undefined;
434
+ }
435
+ }
436
+ /** Where a place is, as people know it: this computer, its URL, or the SSH destination its tunnel goes to. */
437
+ function where(remote) {
438
+ if (remote.ssh)
439
+ return `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
440
+ return remote.name === PAIRED_NAME ? "this computer" : remote.url;
318
441
  }
319
442
  function clientName() {
320
443
  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
  }
@@ -85,5 +171,15 @@ function isRemote(value) {
85
171
  if (!value || typeof value !== "object")
86
172
  return false;
87
173
  const entry = value;
88
- return ["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string");
174
+ if (!["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string"))
175
+ return false;
176
+ return entry.ssh === undefined || isSshAccess(entry.ssh);
177
+ }
178
+ function isSshAccess(value) {
179
+ if (!value || typeof value !== "object")
180
+ return false;
181
+ const ssh = value;
182
+ return typeof ssh.target === "string" && ssh.target.length > 0 && !ssh.target.startsWith("-") &&
183
+ (ssh.port === undefined || (typeof ssh.port === "number" && Number.isInteger(ssh.port) && ssh.port > 0 && ssh.port < 65536)) &&
184
+ (ssh.identityFile === undefined || typeof ssh.identityFile === "string");
89
185
  }
@@ -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
+ export 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
+ export 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
+ }