directionallylocal 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +24 -2
- package/dist/laptop/commands.js +12 -33
- package/dist/laptop/credentials.js +5 -5
- package/dist/laptop/session-store.js +62 -0
- package/dist/laptop/session.js +280 -0
- package/dist/laptop/settings.js +193 -0
- package/dist/laptop/setup.js +110 -63
- package/dist/laptop/tunnel.js +8 -0
- package/dist/local/setup.js +2 -4
- package/dist/prompt.js +333 -16
- package/package.json +4 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fetchModelPrices } from "../model-prices.js";
|
|
5
|
+
import { PromptCancelled, TtyPrompter } from "../prompt.js";
|
|
6
|
+
import { palette, PlainUi, TerminalUi } from "../ui.js";
|
|
7
|
+
import { autopilotConfigPath } from "./credentials.js";
|
|
8
|
+
/**
|
|
9
|
+
* dl's own settings on this laptop (~/.config/directionallylocal/config.json). Today only
|
|
10
|
+
* the model tasks and sessions use when none is named with -m. It's dl's, so people who
|
|
11
|
+
* never installed Autopilot can set it; Autopilot's own default is still honoured when
|
|
12
|
+
* dl has none.
|
|
13
|
+
*/
|
|
14
|
+
/** What tasks and sessions use when neither -m, dl model nor Autopilot names a model. */
|
|
15
|
+
export const DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash-1017";
|
|
16
|
+
/** The suggestions setup and `dl model` offer first; any OpenRouter ID works too. */
|
|
17
|
+
export const SUGGESTED_MODELS = [
|
|
18
|
+
{ id: "deepseek/deepseek-v4.1-flash-1017", note: "fast and cheap" },
|
|
19
|
+
{ id: "moonshotai/kimi-k2.6", note: "strong at agentic coding" },
|
|
20
|
+
{ id: "anthropic/claude-sonnet-5.5", note: "stronger, pricier" },
|
|
21
|
+
{ id: "openai/gpt-6.1-sol", note: "stronger, pricier" },
|
|
22
|
+
];
|
|
23
|
+
export const OPENROUTER_ID_EXAMPLE = "provider/model, e.g. z-ai/glm-5";
|
|
24
|
+
export function settingsPath(env = process.env) {
|
|
25
|
+
const configHome = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
26
|
+
return join(configHome, "directionallylocal", "config.json");
|
|
27
|
+
}
|
|
28
|
+
export async function loadSettings(path = settingsPath()) {
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = await readFile(path, "utf8");
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === "ENOENT")
|
|
35
|
+
return {};
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return typeof parsed.model === "string" && parsed.model.trim() ? { model: parsed.model.trim() } : {};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A damaged file only loses the saved model; the next save rewrites it.
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function saveModel(model, path = settingsPath()) {
|
|
48
|
+
const settings = { ...(await loadSettings(path)), model };
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
51
|
+
await writeFile(temp, `${JSON.stringify({ version: 1, ...settings }, null, 2)}\n`, { mode: 0o600 });
|
|
52
|
+
await rename(temp, path);
|
|
53
|
+
}
|
|
54
|
+
/** The model to use when none is named: dl's setting, else Autopilot's default, with where it came from. */
|
|
55
|
+
export async function preferredModel(autopilotModel, path) {
|
|
56
|
+
const { model } = await loadSettings(path);
|
|
57
|
+
if (model)
|
|
58
|
+
return { model, source: "dl" };
|
|
59
|
+
const fromAutopilot = await autopilotModel();
|
|
60
|
+
return fromAutopilot ? { model: fromAutopilot, source: "autopilot" } : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** This laptop's default model: the one set with `dl model`, else Autopilot's. */
|
|
63
|
+
export async function laptopModel() {
|
|
64
|
+
return (await preferredModel(() => readAutopilotModel()))?.model;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The laptop's Autopilot default model: KIMI_MODEL (Autopilot's own override) or the
|
|
68
|
+
* `model` in its config. undefined when neither is set or the config can't be read.
|
|
69
|
+
*/
|
|
70
|
+
export async function readAutopilotModel(sources = {}) {
|
|
71
|
+
const env = sources.env ?? process.env;
|
|
72
|
+
const fromEnv = env.KIMI_MODEL?.trim();
|
|
73
|
+
if (fromEnv)
|
|
74
|
+
return fromEnv;
|
|
75
|
+
const read = sources.readFile ?? ((path) => readFile(path, "utf8").catch(() => undefined));
|
|
76
|
+
const raw = await read(autopilotConfigPath(env, sources.home ?? homedir()));
|
|
77
|
+
if (!raw)
|
|
78
|
+
return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const model = JSON.parse(raw).model;
|
|
81
|
+
return typeof model === "string" && model.trim() ? model.trim() : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** OpenRouter's prices, or undefined when they can't be fetched: the list then just has no prices. */
|
|
88
|
+
export async function modelPrices(fetchImpl = fetch) {
|
|
89
|
+
return fetchModelPrices(fetchImpl).then(({ table }) => table, () => undefined);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The picker's options: the suggestions (with prices when known, and the current model
|
|
93
|
+
* added if it isn't one), then "Another model", which takes a typed ID.
|
|
94
|
+
*/
|
|
95
|
+
export function modelOptions(current, prices, anotherPrompt) {
|
|
96
|
+
const known = prices ? SUGGESTED_MODELS.filter((entry) => entry.id.toLowerCase() in prices) : SUGGESTED_MODELS;
|
|
97
|
+
const entries = current && !known.some((entry) => entry.id === current) ? [{ id: current, note: "your current model" }, ...known] : known;
|
|
98
|
+
return [
|
|
99
|
+
...entries.map((entry) => ({ value: entry.id, label: entry.id, detail: [entry.note, priceLabel(prices?.[entry.id.toLowerCase()])].filter(Boolean).join(" · ") })),
|
|
100
|
+
{ value: "another", label: "Another model", detail: "any OpenRouter model ID", input: { prompt: anotherPrompt, placeholder: OPENROUTER_ID_EXAMPLE } },
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
function priceLabel(price) {
|
|
104
|
+
if (!price)
|
|
105
|
+
return "";
|
|
106
|
+
if (price.inputPerMTok === 0 && price.outputPerMTok === 0)
|
|
107
|
+
return "free";
|
|
108
|
+
return `$${price.inputPerMTok} in / $${price.outputPerMTok} out per M tokens`;
|
|
109
|
+
}
|
|
110
|
+
/** A typed model ID, or why it isn't one. Checked against OpenRouter's catalog when it's known. */
|
|
111
|
+
export function checkModelId(text, prices) {
|
|
112
|
+
const model = text.trim();
|
|
113
|
+
if (!/^[a-z0-9][\w.-]*\/[\w.:-]+$/i.test(model))
|
|
114
|
+
return { ok: false, message: `"${model}" isn't an OpenRouter model ID (${OPENROUTER_ID_EXAMPLE})` };
|
|
115
|
+
if (prices && !(model.toLowerCase() in prices))
|
|
116
|
+
return { ok: false, message: `OpenRouter has no model ${model} (see https://openrouter.ai/models)` };
|
|
117
|
+
return { ok: true, model };
|
|
118
|
+
}
|
|
119
|
+
/** The arrow-key list of models, with `current` preselected; a typed ID gets 3 tries. */
|
|
120
|
+
export async function askModel(prompter, ui, current, prices) {
|
|
121
|
+
const options = modelOptions(current, prices, ui.prompt("Model ID", OPENROUTER_ID_EXAMPLE));
|
|
122
|
+
const initial = Math.max(0, options.findIndex((option) => option.value === current));
|
|
123
|
+
for (let attempt = 1;; attempt++) {
|
|
124
|
+
const picked = await prompter.select(ui.prompt("Model", "used for tasks and sessions"), options, initial);
|
|
125
|
+
if (picked.value !== "another")
|
|
126
|
+
return picked.value;
|
|
127
|
+
const checked = checkModelId(picked.text ?? "", prices);
|
|
128
|
+
if (checked.ok)
|
|
129
|
+
return checked.model;
|
|
130
|
+
if (attempt >= 3)
|
|
131
|
+
throw new Error(checked.message);
|
|
132
|
+
ui.rejected(`${checked.message}. Try again.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* `dl model [ID]`: the model `dl run` and new sessions use when -m doesn't name one. With an
|
|
137
|
+
* ID, sets it; without, picks from a list on a terminal, or says which it is otherwise.
|
|
138
|
+
*/
|
|
139
|
+
export async function modelCommand(args, deps) {
|
|
140
|
+
const c = palette(deps.color);
|
|
141
|
+
const current = await preferredModel(deps.autopilotModel, deps.settingsFile);
|
|
142
|
+
const model = current?.model ?? DEFAULT_MODEL;
|
|
143
|
+
const from = current?.source === "dl" ? "set with dl model" : current?.source === "autopilot" ? "Autopilot's default" : "dl's default";
|
|
144
|
+
if (args.length > 1) {
|
|
145
|
+
deps.write(`\n ${c.red("✗")} dl model takes one model ID, e.g. dl model ${DEFAULT_MODEL}\n\n`);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
if (args.length === 0 && !deps.interactive) {
|
|
149
|
+
deps.write(`\n ${c.bold("Model")} ${model} ${c.dim(`(${from})`)}\n ${c.dim("Change it:")} dl model <id> ${c.dim("(model IDs: https://openrouter.ai/models)")}\n\n`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
const prices = await modelPrices(deps.fetch);
|
|
153
|
+
let chosen;
|
|
154
|
+
if (args[0] !== undefined) {
|
|
155
|
+
const checked = checkModelId(args[0], prices);
|
|
156
|
+
if (!checked.ok) {
|
|
157
|
+
deps.write(`\n ${c.red("✗")} ${checked.message}\n\n`);
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
chosen = checked.model;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const ui = deps.newUi();
|
|
164
|
+
deps.write("\n");
|
|
165
|
+
try {
|
|
166
|
+
chosen = await askModel(deps.prompter, ui, model, prices);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!(error instanceof PromptCancelled))
|
|
170
|
+
throw error;
|
|
171
|
+
deps.write(` ${c.dim(`Unchanged: ${model}`)}\n\n`);
|
|
172
|
+
return 130;
|
|
173
|
+
}
|
|
174
|
+
ui.finish();
|
|
175
|
+
}
|
|
176
|
+
await saveModel(chosen, deps.settingsFile);
|
|
177
|
+
deps.write(`\n ${c.green("✓")} ${c.bold("Model")} ${chosen}\n ${c.dim("Used by dl run and new sessions. A running session keeps its model; switch it there with")} /model\n\n`);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
/** `dl model` with the real terminal and network. */
|
|
181
|
+
export async function model(args, options) {
|
|
182
|
+
const { io, terminal } = options;
|
|
183
|
+
const color = io.color === true;
|
|
184
|
+
return modelCommand(args, {
|
|
185
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
186
|
+
prompter: new TtyPrompter({ color }),
|
|
187
|
+
write: io.out,
|
|
188
|
+
color,
|
|
189
|
+
interactive: terminal,
|
|
190
|
+
fetch: globalThis.fetch,
|
|
191
|
+
autopilotModel: () => readAutopilotModel(),
|
|
192
|
+
});
|
|
193
|
+
}
|
package/dist/laptop/setup.js
CHANGED
|
@@ -3,21 +3,24 @@ import { access as fileAccess, readdir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { homedir, hostname } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { TtyPrompter } from "../prompt.js";
|
|
6
|
+
import { PromptCancelled, TtyPrompter } from "../prompt.js";
|
|
7
7
|
import { palette, PlainUi, TerminalUi } from "../ui.js";
|
|
8
|
-
import { loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote } from "./config.js";
|
|
8
|
+
import { loadRemotes, loadRemotesFile, normalizeServerUrl, REMOTE_NAME_RE, remotesPath, saveRemote, setDefaultRemote } from "./config.js";
|
|
9
9
|
import { confirm, gatherKeys } from "./credentials.js";
|
|
10
10
|
import { bootstrap, INSTALL_URL, installOnServer, pairLaptop, serverNeeds } from "./remote-install.js";
|
|
11
11
|
import { checkServer, describeHost, discoverKeys, hostOf, parseSshConfig, parseTarget, realSshExec, SshAuthError } from "./ssh.js";
|
|
12
|
+
import { askModel, DEFAULT_MODEL, loadSettings, modelPrices, readAutopilotModel, saveModel } from "./settings.js";
|
|
12
13
|
import { openRemote, withConnections } from "./tunnel.js";
|
|
13
14
|
const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
|
|
15
|
+
const PLACES = [
|
|
16
|
+
{ value: "local", label: "On this computer", detail: "start now · nothing to rent · pauses when this computer sleeps" },
|
|
17
|
+
{ value: "server", label: "On my own server", detail: "keeps going when your laptop is closed · needs SSH access" },
|
|
18
|
+
];
|
|
14
19
|
export async function setupWizard(deps) {
|
|
15
20
|
const c = palette(deps.color);
|
|
16
21
|
deps.write(`\n ${c.bold("Directionally Local")}\n Background coding agents. Where should they run?\n\n`);
|
|
17
|
-
deps.write(` ${c.cyan("1")} ${"On this computer".padEnd(22)} ${c.dim("start now · nothing to rent · pauses when this computer sleeps")}\n`);
|
|
18
|
-
deps.write(` ${c.cyan("2")} ${"On my own server".padEnd(22)} ${c.dim("keeps going when your laptop is closed · needs SSH access")}\n\n`);
|
|
19
22
|
const ui = deps.newUi();
|
|
20
|
-
let where = await
|
|
23
|
+
let where = (await deps.prompter.select(ui.prompt("Where"), PLACES, 0)).value;
|
|
21
24
|
ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
|
|
22
25
|
if (where === "local" && !deps.localSetup) {
|
|
23
26
|
ui.line("info", "On this computer", "coming soon");
|
|
@@ -30,11 +33,16 @@ export async function setupWizard(deps) {
|
|
|
30
33
|
}
|
|
31
34
|
if (where === "local") {
|
|
32
35
|
const keysUi = deps.newUi();
|
|
33
|
-
const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
|
|
36
|
+
const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources, place: "this computer" });
|
|
34
37
|
keysUi.finish();
|
|
35
|
-
const remote = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const { remote, capacity } = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
|
|
39
|
+
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
40
|
+
const isNew = !(await loadRemotes(remotesFile)).some((entry) => entry.name === remote.name);
|
|
41
|
+
await saveRemote(remote, remotesFile);
|
|
42
|
+
if (isNew)
|
|
43
|
+
await chooseDefault(deps, remote, remotesFile);
|
|
44
|
+
await chooseModel(deps);
|
|
45
|
+
finishScreen(deps.newUi(), capacity);
|
|
38
46
|
return remote;
|
|
39
47
|
}
|
|
40
48
|
return setUpServer(deps);
|
|
@@ -61,7 +69,8 @@ async function setUpServer(deps) {
|
|
|
61
69
|
// 4. A credential for this laptop, checked through the tunnel every command will use. Each
|
|
62
70
|
// `dl pair` makes a new credential on the server, so a re-run keeps the saved one if it works.
|
|
63
71
|
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
64
|
-
const
|
|
72
|
+
const known = await loadRemotes(remotesFile);
|
|
73
|
+
const saved = known.find((entry) => sameServer(entry, access));
|
|
65
74
|
let remote;
|
|
66
75
|
if (saved && await stillWorks({ ...saved, ssh: access }, deps.open))
|
|
67
76
|
remote = { ...saved, ssh: access };
|
|
@@ -91,25 +100,26 @@ async function setUpServer(deps) {
|
|
|
91
100
|
await saveRemote(remote, remotesFile);
|
|
92
101
|
connectedUi.line("done", "This laptop", `connected as ${remote.name}`);
|
|
93
102
|
connectedUi.finish();
|
|
103
|
+
if (!known.some((entry) => entry.name === paired.name))
|
|
104
|
+
await chooseDefault(deps, remote, remotesFile);
|
|
105
|
+
await chooseModel(deps);
|
|
94
106
|
finishScreen(deps.newUi(), capacity);
|
|
95
107
|
return remote;
|
|
96
108
|
}
|
|
97
109
|
async function chooseServer(deps, hosts) {
|
|
98
|
-
const c = palette(deps.color);
|
|
99
110
|
const ui = deps.newUi();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
deps.write("\n");
|
|
107
|
-
}
|
|
108
|
-
const hint = hosts.length > 0 ? `1-${hosts.length + 1}, or user@host[:port]` : "user@host[:port], e.g. root@203.0.113.10";
|
|
111
|
+
deps.write("\n");
|
|
112
|
+
const example = "user@host[:port], e.g. root@203.0.113.10";
|
|
113
|
+
const options = [
|
|
114
|
+
...hosts.map((host, index) => ({ value: String(index), label: host.alias, detail: describeHost(host) })),
|
|
115
|
+
{ value: "another", label: "Another server", input: { prompt: ui.prompt("Server address", example), placeholder: example } },
|
|
116
|
+
];
|
|
109
117
|
for (let attempt = 1;; attempt++) {
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
118
|
+
// Without saved hosts, a one-item list would only be in the way: just ask for the address.
|
|
119
|
+
const picked = hosts.length > 0
|
|
120
|
+
? await deps.prompter.select(ui.prompt("Server"), options, 0)
|
|
121
|
+
: { value: "another", text: (await deps.prompter.text(ui.prompt("Server", example))).trim() };
|
|
122
|
+
const host = picked.value === "another" ? undefined : hosts[Number(picked.value)];
|
|
113
123
|
if (host) {
|
|
114
124
|
ui.answered("SSH", host.alias);
|
|
115
125
|
return {
|
|
@@ -118,12 +128,8 @@ async function chooseServer(deps, hosts) {
|
|
|
118
128
|
display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
|
|
119
129
|
};
|
|
120
130
|
}
|
|
121
|
-
if (index === hosts.length && hosts.length > 0) {
|
|
122
|
-
ui.answered("SSH", "another server");
|
|
123
|
-
answer = (await deps.prompter.text(ui.prompt("Server address", "user@host[:port], e.g. root@203.0.113.10"))).trim();
|
|
124
|
-
}
|
|
125
131
|
try {
|
|
126
|
-
const access = parseTarget(
|
|
132
|
+
const access = parseTarget(picked.text ?? "");
|
|
127
133
|
ui.answered("SSH", access.target);
|
|
128
134
|
return { access, display: `${access.target}${access.port ? `:${access.port}` : ""}` };
|
|
129
135
|
}
|
|
@@ -134,6 +140,50 @@ async function chooseServer(deps, hosts) {
|
|
|
134
140
|
}
|
|
135
141
|
}
|
|
136
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* With two or more places set up, where `dl run` sends tasks unless told otherwise. Asked
|
|
145
|
+
* only when setup adds a place, with the new one preselected; the first place becomes the
|
|
146
|
+
* default by itself (saveRemote).
|
|
147
|
+
*/
|
|
148
|
+
async function chooseDefault(deps, added, remotesFile) {
|
|
149
|
+
const { remotes } = await loadRemotesFile(remotesFile);
|
|
150
|
+
if (remotes.length < 2)
|
|
151
|
+
return;
|
|
152
|
+
const ui = deps.newUi();
|
|
153
|
+
deps.write("\n");
|
|
154
|
+
const options = remotes.map((remote) => ({ value: remote.name, label: remote.name, detail: describePlace(remote) }));
|
|
155
|
+
const initial = Math.max(0, remotes.findIndex((remote) => remote.name === added.name));
|
|
156
|
+
const { value } = await deps.prompter.select(ui.prompt("Where should tasks go by default?"), options, initial);
|
|
157
|
+
await setDefaultRemote(value, remotesFile);
|
|
158
|
+
ui.answered("Default", value);
|
|
159
|
+
ui.finish();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Which model tasks and sessions use, asked once: setup skips it when dl already has one
|
|
163
|
+
* (dl model changes it). Autopilot's own default is preselected for people who have one.
|
|
164
|
+
*/
|
|
165
|
+
async function chooseModel(deps) {
|
|
166
|
+
if ((await loadSettings(deps.settingsFile)).model)
|
|
167
|
+
return;
|
|
168
|
+
const [current, prices] = await Promise.all([readAutopilotModel(deps.sources), deps.sources.fetch ? modelPrices(deps.sources.fetch) : undefined]);
|
|
169
|
+
const ui = deps.newUi();
|
|
170
|
+
deps.write("\n");
|
|
171
|
+
const model = await askModel(deps.prompter, ui, current ?? DEFAULT_MODEL, prices);
|
|
172
|
+
await saveModel(model, deps.settingsFile);
|
|
173
|
+
ui.answered("Model", `${model} (change it any time: dl model)`);
|
|
174
|
+
ui.finish();
|
|
175
|
+
}
|
|
176
|
+
function describePlace(remote) {
|
|
177
|
+
if (remote.ssh)
|
|
178
|
+
return `server · ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
|
|
179
|
+
try {
|
|
180
|
+
const host = new URL(remote.url).hostname;
|
|
181
|
+
return host === "127.0.0.1" || host === "localhost" ? "this computer" : `server · ${host}`;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return remote.url;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
137
187
|
/**
|
|
138
188
|
* Connects and checks the server. If it refuses the default keys (ssh's own: the agent and
|
|
139
189
|
* ~/.ssh/id_*), each key pair in ~/.ssh is tried in turn with -i, without asking: auth
|
|
@@ -246,25 +296,13 @@ export function serverLabel(text) {
|
|
|
246
296
|
}
|
|
247
297
|
function finishScreen(ui, capacity) {
|
|
248
298
|
ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
|
|
249
|
-
{ label: "
|
|
250
|
-
{ label: "
|
|
251
|
-
]);
|
|
299
|
+
{ label: "Start a session: cd into a GitHub repository, then type", value: "dl" },
|
|
300
|
+
{ label: "Or hand off a one-off task", value: RUN_EXAMPLE },
|
|
301
|
+
], { footer: "dl status shows where agents run and what they're doing." });
|
|
252
302
|
}
|
|
253
303
|
function laptopName(name) {
|
|
254
304
|
return name.replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
|
255
305
|
}
|
|
256
|
-
async function pick(ui, prompter, label, hint, choices) {
|
|
257
|
-
const values = Object.values(choices);
|
|
258
|
-
for (let attempt = 1;; attempt++) {
|
|
259
|
-
const answer = (await prompter.text(ui.prompt(label, hint))).trim();
|
|
260
|
-
const choice = answer === "" ? values[0] : choices[answer];
|
|
261
|
-
if (choice)
|
|
262
|
-
return choice;
|
|
263
|
-
if (attempt >= 3)
|
|
264
|
-
throw new Error(`Pick one of ${Object.keys(choices).join(", ")}`);
|
|
265
|
-
ui.rejected(`Type ${Object.keys(choices).join(" or ")}.`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
306
|
/** `dl setup` with the real terminal, ssh, gh, and filesystem. */
|
|
269
307
|
export async function setup(options) {
|
|
270
308
|
const { io, terminal } = options;
|
|
@@ -277,25 +315,34 @@ export async function setup(options) {
|
|
|
277
315
|
identityFile = resolve(options.identityFile.replace(/^~(?=$|\/)/, home).replace(/\.pub$/, ""));
|
|
278
316
|
await fileAccess(identityFile).catch(() => { throw new Error(`No SSH key at ${options.identityFile} (--identity takes the private key, e.g. ~/.ssh/id_ed25519)`); });
|
|
279
317
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
318
|
+
try {
|
|
319
|
+
await setupWizard({
|
|
320
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
321
|
+
prompter: new TtyPrompter({ color }),
|
|
322
|
+
write: io.out,
|
|
323
|
+
color,
|
|
324
|
+
ssh: realSshExec,
|
|
325
|
+
open: openRemote,
|
|
326
|
+
sources: {
|
|
327
|
+
env: process.env,
|
|
328
|
+
home,
|
|
329
|
+
readFile: (path) => readFile(path, "utf8").catch(() => undefined),
|
|
330
|
+
listDir: (path) => readdir(path).catch(() => []),
|
|
331
|
+
gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
|
|
332
|
+
fetch: globalThis.fetch,
|
|
333
|
+
},
|
|
334
|
+
hostname: hostname(),
|
|
335
|
+
installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
|
|
336
|
+
...(options.localSetup ? { localSetup: options.localSetup } : {}),
|
|
337
|
+
...(identityFile ? { identityFile } : {}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (!(error instanceof PromptCancelled))
|
|
342
|
+
throw error;
|
|
343
|
+
// Esc / Ctrl-C at a question: the terminal is restored; say so quietly and stop.
|
|
344
|
+
io.out(` ${palette(color).dim("Cancelled. Run")} dl setup ${palette(color).dim("when you're ready.")}\n\n`);
|
|
345
|
+
return 130;
|
|
346
|
+
}
|
|
300
347
|
return 0;
|
|
301
348
|
}
|
package/dist/laptop/tunnel.js
CHANGED
|
@@ -57,6 +57,14 @@ export function createOpener(options = {}) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
export const openRemote = createOpener();
|
|
60
|
+
/**
|
|
61
|
+
* For an interactive session: ssh in its own process group, so Ctrl-C typed into the agent's
|
|
62
|
+
* screen (which the terminal sends to its whole foreground group) doesn't drop the tunnel.
|
|
63
|
+
* The session closes it itself when the agent exits, or when dl is told to stop.
|
|
64
|
+
*/
|
|
65
|
+
export const openRemoteForSession = createOpener({
|
|
66
|
+
spawn: (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"], detached: true }),
|
|
67
|
+
});
|
|
60
68
|
/**
|
|
61
69
|
* The endpoints one command uses: at most one tunnel per server, reused across its
|
|
62
70
|
* requests, all closed when the command ends (commands close it in `finally`, which also
|
package/dist/local/setup.js
CHANGED
|
@@ -221,10 +221,8 @@ export async function setUpOnThisComputer(input) {
|
|
|
221
221
|
expiresAt: new Date(credential.expiresAt).toISOString(),
|
|
222
222
|
addedAt: at,
|
|
223
223
|
};
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
detail: `${url.replace(/^http:\/\//, "")} · fits ~${localCapacity(docker)} at once · tasks pause while this computer sleeps`,
|
|
227
|
-
};
|
|
224
|
+
// The address is an implementation detail here; how many fit goes on the wizard's Ready line.
|
|
225
|
+
return { value: { remote, capacity: localCapacity(docker) }, detail: "this computer · tasks pause while it sleeps" };
|
|
228
226
|
});
|
|
229
227
|
}
|
|
230
228
|
/**
|