directionallylocal 0.4.0 → 0.5.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.
@@ -9,6 +9,8 @@ export class FakeComputer {
9
9
  home;
10
10
  dryRun = false;
11
11
  files = new Map();
12
+ /** Modification times (seconds) for `stat`; set on every write. */
13
+ mtimes = new Map();
12
14
  dirs = new Map();
13
15
  commands = [];
14
16
  probes = [];
@@ -62,6 +64,7 @@ export class FakeComputer {
62
64
  }
63
65
  async writeFile(path, content, options = {}) {
64
66
  this.files.set(path, { content, mode: options.mode ?? 0o644 });
67
+ this.mtimes.set(path, Math.floor(Date.now() / 1000));
65
68
  }
66
69
  async mkdir(path, options = {}) {
67
70
  this.dirs.set(path, options.mode);
@@ -237,6 +240,10 @@ export class FakeComputer {
237
240
  case "ls":
238
241
  return ok([...this.dirs.keys()].filter((dir) => dir.startsWith(`${args[1]}/`) && !dir.slice(args[1].length + 1).includes("/")).map((dir) => dir.split("/").at(-1)).join("\n"));
239
242
  case "mktemp": return ok("/tmp/tmp.build\n");
243
+ case "stat": {
244
+ const mtime = this.files.has(args.at(-1)) ? this.mtimes.get(args.at(-1)) : undefined;
245
+ return mtime === undefined ? fail("No such file or directory") : ok(`${mtime}\n`);
246
+ }
240
247
  case "wc": return ok("0\n");
241
248
  case "/usr/local/bin/docker": {
242
249
  if (args[0] === "context")
@@ -0,0 +1,119 @@
1
+ export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
2
+ const FETCH_TIMEOUT_MS = 15_000;
3
+ /** Older than this, `dl doctor` suggests refreshing it. */
4
+ export const MODEL_PRICES_MAX_AGE_DAYS = 7;
5
+ /** USD per token as OpenRouter gives it ("0.0000001"), or undefined when it isn't a usable price. */
6
+ function perToken(value) {
7
+ const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
8
+ // OpenRouter marks variable pricing with "-1"; genuine 0 is a free model and is kept.
9
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
10
+ }
11
+ /** Per million tokens, without float noise (0.0000001 × 1e6 is 0.09999999999999999). */
12
+ function perMillion(price) {
13
+ return Math.round(price * 1e12) / 1e6;
14
+ }
15
+ /** OpenRouter's catalog response to a price table; entries without usable prices are skipped. */
16
+ export function pricesFromCatalog(catalog) {
17
+ const data = catalog?.data;
18
+ if (!Array.isArray(data))
19
+ throw new Error("OpenRouter's model list had no models");
20
+ const table = {};
21
+ for (const entry of data) {
22
+ const id = typeof entry?.id === "string" ? entry.id.trim().toLowerCase() : "";
23
+ if (!id)
24
+ continue;
25
+ const input = perToken(entry?.pricing?.prompt);
26
+ const output = perToken(entry?.pricing?.completion);
27
+ if (input === undefined || output === undefined)
28
+ continue;
29
+ table[id] = { inputPerMTok: perMillion(input), outputPerMTok: perMillion(output) };
30
+ }
31
+ return table;
32
+ }
33
+ /** Downloads OpenRouter's public model list (no key needed) as a price table. */
34
+ export async function fetchModelPrices(fetchImpl = fetch) {
35
+ let response;
36
+ try {
37
+ response = await fetchImpl(OPENROUTER_MODELS_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { "User-Agent": "directionallylocal" } });
38
+ }
39
+ catch (error) {
40
+ throw new Error(`couldn't reach openrouter.ai: ${error.message}`);
41
+ }
42
+ if (!response.ok)
43
+ throw new Error(`openrouter.ai answered HTTP ${response.status}`);
44
+ const table = pricesFromCatalog(await response.json().catch(() => undefined));
45
+ const count = Object.keys(table).length;
46
+ if (count === 0)
47
+ throw new Error("OpenRouter's model list had no prices");
48
+ return { table, count };
49
+ }
50
+ export function renderModelPrices(table) {
51
+ const sorted = Object.fromEntries(Object.keys(table).sort().map((id) => [id, table[id]]));
52
+ return `${JSON.stringify(sorted, null, 2)}\n`;
53
+ }
54
+ /** How many models a written price file holds, or undefined when it isn't one. */
55
+ export function countModelPrices(text) {
56
+ if (text === undefined)
57
+ return undefined;
58
+ try {
59
+ const parsed = JSON.parse(text);
60
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
61
+ return undefined;
62
+ return Object.values(parsed).filter((price) => typeof price?.inputPerMTok === "number" && typeof price?.outputPerMTok === "number").length;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /**
69
+ * Refreshes the price file. When OpenRouter can't be reached, an existing file is kept;
70
+ * either way it warns rather than failing the install. Returns a step detail, if any.
71
+ */
72
+ export async function refreshModelPrices(system, input) {
73
+ if (system.dryRun) {
74
+ system.log(` would fetch: ${OPENROUTER_MODELS_URL} -> ${input.path}`);
75
+ return undefined;
76
+ }
77
+ try {
78
+ const { table, count } = await fetchModelPrices(input.fetchImpl);
79
+ await system.writeFile(input.path, renderModelPrices(table), input.write);
80
+ return `prices for ${count} models`;
81
+ }
82
+ catch (error) {
83
+ const reason = error.message;
84
+ const kept = countModelPrices(await system.readFile(input.path));
85
+ if (kept !== undefined && kept > 0) {
86
+ input.warn(`Couldn't refresh model prices (${reason}); kept the last list (${kept} models).`);
87
+ }
88
+ else {
89
+ input.warn(`Couldn't download model prices (${reason}). Tasks will fail with "model not priced" until ${input.retry} can reach openrouter.ai.`);
90
+ }
91
+ return undefined;
92
+ }
93
+ }
94
+ /** For `dl doctor` and `dl local status`: the file's contents and its age (mtime, seconds). */
95
+ export function modelPricesHealth(text, mtimeSeconds, now, refresh) {
96
+ if (text === undefined)
97
+ return { ok: false, warn: false, detail: `missing; tasks fail with "model not priced"` };
98
+ const count = countModelPrices(text);
99
+ if (count === undefined || count === 0)
100
+ return { ok: false, warn: false, detail: "unreadable; tasks may fail with \"model not priced\"" };
101
+ const models = `${count} model${count === 1 ? "" : "s"}`;
102
+ if (mtimeSeconds === undefined)
103
+ return { ok: true, warn: false, detail: models };
104
+ const days = Math.max(0, Math.floor((now.getTime() / 1000 - mtimeSeconds) / 86_400));
105
+ const age = days === 0 ? "updated today" : `${days} day${days === 1 ? "" : "s"} old`;
106
+ if (days > MODEL_PRICES_MAX_AGE_DAYS)
107
+ return { ok: true, warn: true, detail: `${models} · ${age}; ${refresh} refreshes it` };
108
+ return { ok: true, warn: false, detail: `${models} · ${age}` };
109
+ }
110
+ /** A file's mtime in seconds via stat (GNU on Linux, BSD on macOS); undefined when unknown. */
111
+ export async function fileMtime(system, path, platform = "linux") {
112
+ const result = await system.probe("stat", platform === "darwin" ? ["-f", "%m", path] : ["-c", "%Y", path]);
113
+ const seconds = Number(result.stdout.trim());
114
+ return result.code === 0 && result.stdout.trim() !== "" && Number.isFinite(seconds) ? seconds : undefined;
115
+ }
116
+ /** A failed run's reason when Hotcell refused the model for lack of a price. */
117
+ export function isModelNotPriced(text) {
118
+ return /model not priced/i.test(text);
119
+ }
package/dist/prompt.js CHANGED
@@ -1,11 +1,52 @@
1
1
  import { openSync, closeSync } from "node:fs";
2
2
  import { createInterface } from "node:readline";
3
3
  import { ReadStream, WriteStream } from "node:tty";
4
+ import { palette } from "./ui.js";
5
+ /** Thrown when someone presses Esc or Ctrl-C at a question; the terminal is already restored. */
6
+ export class PromptCancelled extends Error {
7
+ constructor() {
8
+ super("Cancelled.");
9
+ this.name = "PromptCancelled";
10
+ }
11
+ }
12
+ function openDevTty() {
13
+ let fd;
14
+ try {
15
+ fd = openSync("/dev/tty", "r+");
16
+ }
17
+ catch {
18
+ throw new Error("No terminal to ask on; pass the answers as environment variables (see `dl --help`).");
19
+ }
20
+ const input = new ReadStream(fd);
21
+ const output = new WriteStream(fd);
22
+ return {
23
+ input,
24
+ output,
25
+ close: () => {
26
+ input.destroy();
27
+ output.destroy();
28
+ try {
29
+ closeSync(fd);
30
+ }
31
+ catch { /* already closed with the streams */ }
32
+ },
33
+ };
34
+ }
35
+ const HIDE_CURSOR = "\x1b[?25l";
36
+ const SHOW_CURSOR = "\x1b[?25h";
4
37
  /**
5
38
  * Reads from /dev/tty, so prompts work under `curl … | sh` where stdin is the script.
6
39
  * Without a terminal every question fails with the env var that answers it.
7
40
  */
8
41
  export class TtyPrompter {
42
+ open;
43
+ color;
44
+ env;
45
+ constructor(options = {}) {
46
+ this.open = options.open ?? openDevTty;
47
+ this.env = options.env ?? process.env;
48
+ this.color = options.color ?? (!this.env.NO_COLOR && this.env.TERM !== "dumb");
49
+ }
9
50
  async choose(question, options, fallback) {
10
51
  const lines = options.map((option, index) => ` ${index + 1}. ${option.label}`).join("\n");
11
52
  const answer = (await this.ask(`${question}\n${lines}\nChoice [1]: `, false)).trim();
@@ -14,22 +55,50 @@ export class TtyPrompter {
14
55
  const index = Number(answer) - 1;
15
56
  return options[index]?.value ?? options.find((option) => option.value === answer)?.value ?? fallback;
16
57
  }
58
+ async select(question, options, initial = 0) {
59
+ if (options.length === 0)
60
+ throw new Error("select() needs at least one option");
61
+ const start = Math.min(Math.max(0, initial), options.length - 1);
62
+ const terminal = this.open();
63
+ const raw = typeof terminal.input.setRawMode === "function" && terminal.input.isTTY === true && this.env.TERM !== "dumb";
64
+ if (!raw) {
65
+ terminal.close();
66
+ return this.numbered(question, options, start);
67
+ }
68
+ return new ArrowSelect(terminal, question, options, start, this.color).run();
69
+ }
17
70
  secret(question) {
18
71
  return this.ask(question, true);
19
72
  }
20
73
  text(question) {
21
74
  return this.ask(question, false);
22
75
  }
23
- ask(question, hidden) {
24
- let fd;
25
- try {
26
- fd = openSync("/dev/tty", "r+");
27
- }
28
- catch {
29
- throw new Error("No terminal to ask on; pass the answers as environment variables (see `dl --help`).");
76
+ /** The plain fallback: a numbered list, then a typed choice (Enter for the preselected one). */
77
+ async numbered(question, options, start) {
78
+ const c = palette(this.color);
79
+ const width = Math.max(...options.map((option) => option.label.length)) + 2;
80
+ const list = options.map((option, index) => ` ${c.cyan(String(index + 1))} ${option.detail ? `${option.label.padEnd(width)} ${c.dim(option.detail)}` : option.label}`).join("\n");
81
+ const typed = options.find((option) => option.input);
82
+ const range = options.length === 2 ? "1 or 2" : `1-${options.length}`;
83
+ const hint = `${range}${typed ? `, or ${typed.input.placeholder.split(",")[0]}` : ""} · Enter for ${start + 1}`;
84
+ const split = question.lastIndexOf("\n");
85
+ const styled = split >= 0 ? `${question.slice(0, split)} ${c.dim(hint)}${question.slice(split)}` : question;
86
+ for (let attempt = 1;; attempt++) {
87
+ const answer = (await this.ask(`${attempt === 1 ? `${list}\n\n` : ""}${styled}`, false)).trim();
88
+ const index = answer === "" ? start : /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
89
+ const option = options[index];
90
+ if (option?.input)
91
+ return { value: option.value, text: (await this.ask(option.input.prompt, false)).trim() };
92
+ if (option)
93
+ return { value: option.value };
94
+ if (typed && answer)
95
+ return { value: typed.value, text: answer };
96
+ if (attempt >= 3)
97
+ throw new Error(`Pick one of ${range}`);
30
98
  }
31
- const input = new ReadStream(fd);
32
- const output = new WriteStream(fd);
99
+ }
100
+ ask(question, hidden) {
101
+ const { input, output, close } = this.open();
33
102
  // A styled prompt is "label line\n› ": print the label line ourselves and give readline
34
103
  // only the last line, so its redraws (and the hidden-input filter) stay on one line.
35
104
  const split = question.lastIndexOf("\n");
@@ -38,7 +107,7 @@ export class TtyPrompter {
38
107
  if (head)
39
108
  output.write(head);
40
109
  return new Promise((resolve) => {
41
- const rl = createInterface({ input, output, terminal: true });
110
+ const rl = createInterface({ input, output, terminal: input.isTTY === true });
42
111
  if (hidden) {
43
112
  // Echo nothing while the secret is typed.
44
113
  rl._writeToOutput = (text) => {
@@ -50,17 +119,248 @@ export class TtyPrompter {
50
119
  if (hidden)
51
120
  output.write("\n");
52
121
  rl.close();
53
- input.destroy();
54
- output.destroy();
55
- try {
56
- closeSync(fd);
57
- }
58
- catch { /* already closed with the streams */ }
122
+ close();
59
123
  resolve(answer.trim());
60
124
  });
61
125
  });
62
126
  }
63
127
  }
128
+ /** Splits a chunk of terminal input into keys (one chunk can hold several, e.g. a paste). */
129
+ export function parseKeys(chunk) {
130
+ const keys = [];
131
+ let text = "";
132
+ const push = (key) => {
133
+ if (text)
134
+ keys.push({ text });
135
+ text = "";
136
+ keys.push(key);
137
+ };
138
+ for (let i = 0; i < chunk.length; i++) {
139
+ const ch = chunk[i];
140
+ if (ch === "\x1b") {
141
+ const next = chunk[i + 1];
142
+ if (next === "[" || next === "O") {
143
+ // CSI / SS3: skip the parameters up to the final byte.
144
+ let end = i + 2;
145
+ while (end < chunk.length && !/[A-Za-z~]/.test(chunk[end]))
146
+ end++;
147
+ const final = chunk[end];
148
+ if (final === "A")
149
+ push("up");
150
+ else if (final === "B")
151
+ push("down");
152
+ i = end;
153
+ }
154
+ else {
155
+ push("escape");
156
+ }
157
+ }
158
+ else if (ch === "\r" || ch === "\n") {
159
+ push("enter");
160
+ }
161
+ else if (ch === "\x03" || ch === "\x04") {
162
+ push("cancel");
163
+ }
164
+ else if (ch === "\x7f" || ch === "\b") {
165
+ push("backspace");
166
+ }
167
+ else if (ch === "\x15") {
168
+ push("clear");
169
+ }
170
+ else if (ch >= " ") {
171
+ text += ch;
172
+ }
173
+ }
174
+ if (text)
175
+ keys.push({ text });
176
+ return keys;
177
+ }
178
+ /**
179
+ * The arrow-key list, drawn inline under the question in normal scrollback: ↑/↓ or j/k move
180
+ * the `›` cursor, a digit jumps, Enter picks, Esc or Ctrl-C cancels. It redraws only its own
181
+ * lines and hides the cursor meanwhile; raw mode and the cursor come back on every way out,
182
+ * including a crash or a SIGINT/SIGTERM from elsewhere.
183
+ */
184
+ class ArrowSelect {
185
+ terminal;
186
+ options;
187
+ cursor;
188
+ typing = false;
189
+ text = "";
190
+ digits = "";
191
+ drawn = 0;
192
+ head;
193
+ marker;
194
+ caret;
195
+ c;
196
+ constructor(terminal, question, options, start, color) {
197
+ this.terminal = terminal;
198
+ this.options = options;
199
+ this.c = palette(color);
200
+ this.cursor = start;
201
+ this.caret = color ? "\x1b[7m \x1b[27m" : "▏";
202
+ const split = question.lastIndexOf("\n");
203
+ this.head = split >= 0 ? question.slice(0, split) : question.trimEnd();
204
+ this.marker = split >= 0 ? question.slice(split + 1) : `${this.c.cyan("›")} `;
205
+ }
206
+ run() {
207
+ const { input, output } = this.terminal;
208
+ return new Promise((resolve, reject) => {
209
+ let finished = false;
210
+ const restore = () => {
211
+ input.setRawMode?.(false);
212
+ output.write(SHOW_CURSOR);
213
+ };
214
+ const onExit = () => restore();
215
+ const onSignal = () => { restore(); process.exit(130); };
216
+ const end = (outcome) => {
217
+ if (finished)
218
+ return;
219
+ finished = true;
220
+ input.off("data", onData);
221
+ process.off("exit", onExit);
222
+ process.off("SIGINT", onSignal);
223
+ process.off("SIGTERM", onSignal);
224
+ restore();
225
+ this.terminal.close();
226
+ outcome();
227
+ };
228
+ const onData = (chunk) => {
229
+ try {
230
+ for (const key of parseKeys(typeof chunk === "string" ? chunk : chunk.toString("utf8"))) {
231
+ const result = this.handle(key);
232
+ if (result === "cancel") {
233
+ this.erase(true);
234
+ end(() => reject(new PromptCancelled()));
235
+ return;
236
+ }
237
+ if (result) {
238
+ // Leave what an answered text prompt leaves: the question and `› choice`.
239
+ this.erase(false);
240
+ output.write(`${this.marker}${result.text ?? this.options.find((option) => option.value === result.value).label}\n`);
241
+ end(() => resolve(result));
242
+ return;
243
+ }
244
+ }
245
+ this.draw();
246
+ }
247
+ catch (error) {
248
+ end(() => reject(error));
249
+ }
250
+ };
251
+ process.once("exit", onExit);
252
+ process.on("SIGINT", onSignal);
253
+ process.on("SIGTERM", onSignal);
254
+ input.setRawMode(true);
255
+ input.on("data", onData);
256
+ input.resume();
257
+ output.write(`${HIDE_CURSOR}${this.fit(this.head, this.c.dim(" ↑/↓ to move · Enter to pick"))}\n`);
258
+ this.draw();
259
+ });
260
+ }
261
+ /** Applies a key: a result when something was picked, "cancel" to stop, else nothing (redraw). */
262
+ handle(key) {
263
+ if (key === "cancel")
264
+ return "cancel";
265
+ if (this.typing) {
266
+ if (key === "escape") {
267
+ this.typing = false;
268
+ this.text = "";
269
+ }
270
+ else if (key === "enter")
271
+ return this.text.trim() ? { value: this.options[this.cursor].value, text: this.text.trim() } : undefined;
272
+ else if (key === "backspace")
273
+ this.text = this.text.slice(0, -1);
274
+ else if (key === "clear")
275
+ this.text = "";
276
+ else if (typeof key === "object")
277
+ this.text += key.text;
278
+ return undefined;
279
+ }
280
+ if (key === "escape")
281
+ return "cancel";
282
+ if (key === "up")
283
+ this.move(-1);
284
+ else if (key === "down")
285
+ this.move(1);
286
+ else if (key === "enter") {
287
+ const option = this.options[this.cursor];
288
+ if (!option.input)
289
+ return { value: option.value };
290
+ this.typing = true;
291
+ }
292
+ else if (typeof key === "object") {
293
+ for (const ch of key.text) {
294
+ if (ch === "k")
295
+ this.move(-1);
296
+ else if (ch === "j")
297
+ this.move(1);
298
+ else if (/\d/.test(ch))
299
+ this.jump(ch);
300
+ }
301
+ }
302
+ return undefined;
303
+ }
304
+ move(by) {
305
+ this.digits = "";
306
+ this.cursor = (this.cursor + by + this.options.length) % this.options.length;
307
+ }
308
+ /** A digit jumps to that option; with 10 or more, a second digit right after makes 10+. */
309
+ jump(digit) {
310
+ const combined = Number(this.digits + digit) - 1;
311
+ if (this.digits && combined < this.options.length) {
312
+ this.cursor = combined;
313
+ this.digits = "";
314
+ return;
315
+ }
316
+ const single = Number(digit) - 1;
317
+ if (single >= 0 && single < this.options.length)
318
+ this.cursor = single;
319
+ this.digits = this.options.length >= 10 && single >= 0 ? digit : "";
320
+ }
321
+ lines() {
322
+ if (this.typing) {
323
+ const option = this.options[this.cursor];
324
+ const placeholder = this.text ? "" : this.c.dim(option.input.placeholder);
325
+ // The real cursor stays hidden (it sits below the list); draw one where typing goes.
326
+ return [this.c.dim(` ${option.label} · Esc to go back`), `${this.marker}${this.text}${this.caret}${placeholder}`];
327
+ }
328
+ const width = Math.max(...this.options.map((option) => option.label.length)) + 2;
329
+ return this.options.map((option, index) => {
330
+ const selected = index === this.cursor;
331
+ const label = option.detail ? option.label.padEnd(width) : option.label;
332
+ const line = ` ${selected ? this.c.cyan("›") : " "} ${selected ? this.c.bold(label) : label}`;
333
+ return this.fit(line, option.detail ? this.c.dim(option.detail) : "");
334
+ });
335
+ }
336
+ /** Redraws the lines under the question in place; lines never wrap, so the count holds. */
337
+ draw() {
338
+ const lines = this.lines();
339
+ const rewind = this.drawn > 0 ? `\x1b[${this.drawn}F` : "";
340
+ // When there are fewer lines than last time (typing mode), clear what's left below.
341
+ const stale = this.drawn > lines.length ? "\x1b[J" : "";
342
+ this.terminal.output.write(`${rewind}${lines.map((line) => `\x1b[2K${line}\n`).join("")}${stale}`);
343
+ this.drawn = lines.length;
344
+ }
345
+ /** Erases the list (and, when cancelling, the question too). */
346
+ erase(all) {
347
+ const up = this.drawn + (all ? 1 : 0);
348
+ this.terminal.output.write(`${up > 0 ? `\x1b[${up}F` : ""}\x1b[J`);
349
+ this.drawn = 0;
350
+ }
351
+ /** `styled` plus a dim tail, the tail cut so the line fits the terminal's width. */
352
+ fit(styled, tail) {
353
+ const columns = this.terminal.output.columns || 80;
354
+ const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "");
355
+ const room = columns - 1 - visible(styled).length;
356
+ const plainTail = visible(tail);
357
+ if (plainTail.length <= room)
358
+ return `${styled}${tail}`;
359
+ if (room <= 1)
360
+ return styled;
361
+ return `${styled}${this.c.dim(`${plainTail.slice(0, room - 1)}…`)}`;
362
+ }
363
+ }
64
364
  /** Answers from a map (tests); a missing answer is a test bug, so it throws. */
65
365
  export class ScriptedPrompter {
66
366
  answers;
@@ -71,6 +371,23 @@ export class ScriptedPrompter {
71
371
  async choose(question) {
72
372
  return this.answer(question);
73
373
  }
374
+ /**
375
+ * An answer is a number (1-based), an option's value or label, or "" for the preselected
376
+ * one. Picking an option that takes input then asks its prompt; other text goes to it directly.
377
+ */
378
+ async select(question, options, initial = 0) {
379
+ const answer = this.answer(question).trim();
380
+ const index = answer === "" ? initial : /^\d+$/.test(answer) ? Number(answer) - 1 : options.findIndex((option) => option.value === answer || option.label === answer);
381
+ const option = options[index];
382
+ if (option?.input)
383
+ return { value: option.value, text: this.answer(option.input.prompt).trim() };
384
+ if (option)
385
+ return { value: option.value };
386
+ const typed = options.find((entry) => entry.input);
387
+ if (typed)
388
+ return { value: typed.value, text: answer };
389
+ throw new Error(`no option "${answer}" for: ${question}`);
390
+ }
74
391
  async secret(question) {
75
392
  return this.answer(question);
76
393
  }
package/dist/up.js CHANGED
@@ -5,6 +5,7 @@ import { AUTOPILOT_PORT, CELL_MEMORY_MB, GITHUB_PROXY_PORT, HOTCELL_EGRESS_PORT,
5
5
  import { ACCESS_MODES, readState, writeState } from "./state.js";
6
6
  import { PlainUi } from "./ui.js";
7
7
  import { ownerLink } from "./server-api.js";
8
+ import { refreshModelPrices } from "./model-prices.js";
8
9
  const DRY_RUN_BRIDGE_IP = "172.17.0.1";
9
10
  const DRY_RUN_URL = "https://<this-machine>.<tailnet>.ts.net";
10
11
  /** `dl up`: install or repair Hotcell + Autopilot as services. Safe to re-run. */
@@ -103,6 +104,14 @@ export async function up(system, options) {
103
104
  const hotcellValues = parseEnvFile(await system.readFile(PATHS.hotcellEnv));
104
105
  const apiKey = hotcellValues.HOTCELL_API_KEY || randomBytes(24).toString("hex");
105
106
  await system.mkdir(PATHS.hotcellEtc, { mode: 0o750, owner: `root:${USERS.hotcell}` });
107
+ // Read once at Hotcell's start, so it's in place before the restart below.
108
+ const prices = await refreshModelPrices(system, {
109
+ path: PATHS.modelPrices,
110
+ write: { mode: 0o644, owner: `${USERS.hotcell}:${USERS.hotcell}` },
111
+ fetchImpl,
112
+ warn: (text) => ui.warn(text),
113
+ retry: "dl up",
114
+ });
106
115
  await system.writeFile(PATHS.hotcellEnv, hotcellEnv({ apiKey, bridgeIp }), { mode: 0o640, owner: `root:${USERS.hotcell}`, secret: true });
107
116
  if (!(await system.readFile(PATHS.asterConfig))) {
108
117
  await system.writeFile(PATHS.asterConfig, asterConfig(), { mode: 0o640, owner: `root:${USERS.autopilot}` });
@@ -118,7 +127,7 @@ export async function up(system, options) {
118
127
  await system.run("systemctl", ["restart", SERVICES.hotcell]);
119
128
  await waitUntil(system, sleep, "Hotcell", async () => (await system.probe("curl", ["-fsS", "-o", "/dev/null", `http://127.0.0.1:${HOTCELL_PORT}/`])).code === 0
120
129
  || (await system.probe("sh", ["-c", `ss -ltn | grep -q '127.0.0.1:${HOTCELL_PORT}'`])).code === 0);
121
- return { value: apiKey, detail: "sandboxes and keys ready" };
130
+ return { value: apiKey, detail: `sandboxes and keys ready${prices ? ` · ${prices}` : ""}` };
122
131
  });
123
132
  const url = await step("access", async () => {
124
133
  // Moving off Tailscale: stop publishing on the tailnet, so the only way in is the new one.
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "directionallylocal",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
5
+ "license": "Apache-2.0",
5
6
  "homepage": "https://directionallylocal.com",
6
7
  "repository": {
7
8
  "type": "git",
@@ -34,5 +35,8 @@
34
35
  "@types/node": "^22.10.0",
35
36
  "tsx": "^4.19.2",
36
37
  "typescript": "^5.7.2"
38
+ },
39
+ "dependencies": {
40
+ "autopilot-ai": "^2.7.0"
37
41
  }
38
42
  }