directionallylocal 0.4.1 → 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.
- package/dist/cli.js +20 -2
- package/dist/laptop/commands.js +2 -1
- package/dist/laptop/credentials.js +5 -5
- package/dist/laptop/session-store.js +62 -0
- package/dist/laptop/session.js +279 -0
- package/dist/laptop/setup.js +92 -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
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, se
|
|
|
7
7
|
import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, use, watch } from "./laptop/commands.js";
|
|
8
8
|
import { loadRemotes } from "./laptop/config.js";
|
|
9
9
|
import { keys } from "./laptop/keys.js";
|
|
10
|
+
import { projectSession } from "./laptop/session.js";
|
|
10
11
|
import { setup } from "./laptop/setup.js";
|
|
11
12
|
import { localCommand } from "./local/cli.js";
|
|
12
13
|
import { setUpOnThisComputer } from "./local/setup.js";
|
|
@@ -23,6 +24,9 @@ Send a task; it runs in its own sandbox and comes back as a pull request.
|
|
|
23
24
|
On your laptop:
|
|
24
25
|
dl set up the first time; afterwards, an overview
|
|
25
26
|
dl setup set up or repair where your agents run (safe to re-run)
|
|
27
|
+
dl (in a GitHub repo) talk to the repository's agent; it keeps running when you leave
|
|
28
|
+
dl new in a GitHub repo: start a fresh conversation for it
|
|
29
|
+
dl status where your agents run and what they're doing (works anywhere)
|
|
26
30
|
dl connect URL connect to a server someone else set up (approve the code it shows)
|
|
27
31
|
dl run "TASK" run a task, from inside a GitHub repository
|
|
28
32
|
(your uncommitted changes go along; Ctrl-C stops watching, not the task)
|
|
@@ -206,8 +210,15 @@ export async function main(argv, io = {
|
|
|
206
210
|
if (!state) {
|
|
207
211
|
if (values.brief)
|
|
208
212
|
return 0;
|
|
209
|
-
//
|
|
210
|
-
if (!command && await
|
|
213
|
+
// Inside a GitHub repository, that repository's conversation; elsewhere, the laptop's home.
|
|
214
|
+
if (!command && terminal && (await loadRemotes()).length > 0) {
|
|
215
|
+
const code = await projectSession({ cwd: process.cwd(), ...(values.server ? { server: values.server } : {}), ...(values.model ? { model: values.model } : {}) }, laptopIO);
|
|
216
|
+
if (code !== undefined)
|
|
217
|
+
return code;
|
|
218
|
+
}
|
|
219
|
+
// A laptop connected to servers gets its own home (`dl status` shows it from anywhere);
|
|
220
|
+
// otherwise, what this is and how to start.
|
|
221
|
+
if ((!command || command === "status") && await laptopHome(laptopIO))
|
|
211
222
|
return 0;
|
|
212
223
|
// A laptop with nothing set up yet: the wizard (it needs someone to answer it).
|
|
213
224
|
if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
|
|
@@ -271,6 +282,13 @@ export async function main(argv, io = {
|
|
|
271
282
|
await pull({ id, cwd: process.cwd(), ...server }, laptopIO);
|
|
272
283
|
return 0;
|
|
273
284
|
}
|
|
285
|
+
case "new": {
|
|
286
|
+
const code = await projectSession({ cwd: process.cwd(), fresh: true, ...(values.server ? { server: values.server } : {}), ...(values.model ? { model: values.model } : {}) }, laptopIO);
|
|
287
|
+
if (code !== undefined)
|
|
288
|
+
return code;
|
|
289
|
+
io.err(` dl new starts a fresh conversation from inside a GitHub repository (with something set up: dl setup).\n`);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
274
292
|
case "servers":
|
|
275
293
|
return await servers(parsed.positionals.slice(1), laptopIO);
|
|
276
294
|
case "use":
|
package/dist/laptop/commands.js
CHANGED
|
@@ -283,7 +283,8 @@ export async function laptopHome(io = processIO, open) {
|
|
|
283
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
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`);
|
|
285
285
|
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
|
|
286
|
-
io.out(` ${c.dim("
|
|
286
|
+
io.out(` ${c.dim("Start a session: cd into a GitHub repository, then type")} ${c.bold("dl")}\n`);
|
|
287
|
+
io.out(` ${c.dim("Or hand off a one-off task:")} ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
287
288
|
return true;
|
|
288
289
|
}
|
|
289
290
|
async function follow(client, remote, conversationId, cursor, signal, io, replayUntil) {
|
|
@@ -45,14 +45,14 @@ export async function gatherKeys(needs, input) {
|
|
|
45
45
|
const wanted = needs.filter((need) => need === "github" || need === "openrouter");
|
|
46
46
|
if (wanted.length === 0)
|
|
47
47
|
return answers;
|
|
48
|
-
ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on your server only; then it sets itself up.`);
|
|
48
|
+
ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on ${input.place ?? "your server"} only; then it sets itself up.`);
|
|
49
49
|
if (wanted.includes("github"))
|
|
50
50
|
answers.githubToken = await githubToken(input);
|
|
51
51
|
if (wanted.includes("openrouter"))
|
|
52
52
|
answers.openrouterKey = await openrouterKey(input);
|
|
53
53
|
return answers;
|
|
54
54
|
}
|
|
55
|
-
export async function githubToken({ ui, prompter, sources }) {
|
|
55
|
+
export async function githubToken({ ui, prompter, sources, place = "your server" }) {
|
|
56
56
|
const fromCli = (await sources.gh().catch(() => undefined))?.trim();
|
|
57
57
|
if (fromCli) {
|
|
58
58
|
const checked = await validateGitHubToken(fromCli, sources.fetch);
|
|
@@ -61,7 +61,7 @@ export async function githubToken({ ui, prompter, sources }) {
|
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
63
63
|
const who = checked.login ? ` (${checked.login})` : "";
|
|
64
|
-
if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on
|
|
64
|
+
if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on ${place}.`)) {
|
|
65
65
|
ui.answered("GitHub", `${checked.login ? `signed in as ${checked.login} · ` : ""}from the GitHub CLI`);
|
|
66
66
|
return fromCli;
|
|
67
67
|
}
|
|
@@ -73,7 +73,7 @@ export 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
|
-
export async function openrouterKey({ ui, prompter, sources }) {
|
|
76
|
+
export async function openrouterKey({ ui, prompter, sources, place = "your server" }) {
|
|
77
77
|
const candidates = [];
|
|
78
78
|
const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
|
|
79
79
|
if (fromEnv)
|
|
@@ -88,7 +88,7 @@ export async function openrouterKey({ ui, prompter, sources }) {
|
|
|
88
88
|
ui.warn(`The OpenRouter key ${candidate.from} didn't work (${problem})`);
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
91
|
-
if (await confirm(ui, prompter, `${candidate.question} It stays on
|
|
91
|
+
if (await confirm(ui, prompter, `${candidate.question} It stays on ${place}.`)) {
|
|
92
92
|
ui.answered("OpenRouter", candidate.from);
|
|
93
93
|
return candidate.key;
|
|
94
94
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function sessionsPath(env = process.env) {
|
|
5
|
+
const configHome = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
6
|
+
return join(configHome, "directionallylocal", "sessions.json");
|
|
7
|
+
}
|
|
8
|
+
export async function loadSessions(path = sessionsPath()) {
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await readFile(path, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === "ENOENT")
|
|
15
|
+
return [];
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
return Array.isArray(parsed.sessions) ? parsed.sessions.filter(isEntry) : [];
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// A damaged file only costs a new conversation; it gets rewritten on the next save.
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function findSession(key, path = sessionsPath()) {
|
|
28
|
+
return (await loadSessions(path)).find((entry) => sameKey(entry, key));
|
|
29
|
+
}
|
|
30
|
+
/** Remembers (or replaces) the conversation for this place + repository + checkout. */
|
|
31
|
+
export async function rememberSession(entry, path = sessionsPath()) {
|
|
32
|
+
const kept = (await loadSessions(path)).filter((existing) => !sameKey(existing, entry));
|
|
33
|
+
kept.push({ ...entry, updatedAt: new Date().toISOString() });
|
|
34
|
+
await writeSessions(kept, path);
|
|
35
|
+
}
|
|
36
|
+
/** Forgets this key's conversation (it keeps running on the place); true when there was one. */
|
|
37
|
+
export async function forgetSession(key, path = sessionsPath()) {
|
|
38
|
+
const sessions = await loadSessions(path);
|
|
39
|
+
const kept = sessions.filter((entry) => !sameKey(entry, key));
|
|
40
|
+
if (kept.length === sessions.length)
|
|
41
|
+
return false;
|
|
42
|
+
await writeSessions(kept, path);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
function sameKey(a, b) {
|
|
46
|
+
return a.place === b.place && a.repository === b.repository && a.root === b.root;
|
|
47
|
+
}
|
|
48
|
+
async function writeSessions(sessions, path) {
|
|
49
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
50
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
51
|
+
const body = { version: 1, sessions };
|
|
52
|
+
await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
53
|
+
await chmod(temp, 0o600);
|
|
54
|
+
await rename(temp, path);
|
|
55
|
+
}
|
|
56
|
+
function isEntry(value) {
|
|
57
|
+
if (!value || typeof value !== "object")
|
|
58
|
+
return false;
|
|
59
|
+
const entry = value;
|
|
60
|
+
return ["place", "repository", "root", "conversationId", "updatedAt"].every((key) => typeof entry[key] === "string") &&
|
|
61
|
+
(entry.branch === undefined || typeof entry.branch === "string");
|
|
62
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { constants, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, delimiter, dirname, join, resolve as resolvePath } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { palette } from "../ui.js";
|
|
9
|
+
import { RemoteApiError } from "./client.js";
|
|
10
|
+
import { describeError, pickModel, readAutopilotModel } from "./commands.js";
|
|
11
|
+
import { recordLastUsed, resolveTarget } from "./config.js";
|
|
12
|
+
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
13
|
+
import { findSession, forgetSession, rememberSession, sessionsPath } from "./session-store.js";
|
|
14
|
+
import { openRemoteForSession, withConnections } from "./tunnel.js";
|
|
15
|
+
/**
|
|
16
|
+
* `dl` inside a project: Autopilot's own interactive screen, attached to this repository's
|
|
17
|
+
* conversation on the place agents run (`autopilot attach-remote`). The first time it starts
|
|
18
|
+
* one from this checkout (base commit + uncommitted changes, as `dl run` sends them); after
|
|
19
|
+
* that it reopens the same one. dl only finds the conversation, holds the SSH tunnel open,
|
|
20
|
+
* and gets out of the way: Autopilot owns the terminal until the user detaches.
|
|
21
|
+
*/
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
const realSpawn = (command, args, env) => spawn(command, args, { stdio: "inherit", env });
|
|
24
|
+
const FORWARDED = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
25
|
+
const UPDATE = "npm install -g directionallylocal";
|
|
26
|
+
class Interrupted extends Error {
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Opens the session, or returns undefined when this isn't a project dl can open one for
|
|
30
|
+
* (not a Git repository, origin not on GitHub, nothing pushed yet, or no place to pick),
|
|
31
|
+
* after a one-line note for the last three, so the caller shows the overview instead.
|
|
32
|
+
*/
|
|
33
|
+
export async function projectSession(input, io) {
|
|
34
|
+
const c = palette(io.color === true);
|
|
35
|
+
const note = (text) => io.err(` ${c.yellow("!")} ${text}\n`);
|
|
36
|
+
const root = await gitRoot(input.cwd);
|
|
37
|
+
if (!root)
|
|
38
|
+
return undefined;
|
|
39
|
+
let repository;
|
|
40
|
+
try {
|
|
41
|
+
repository = await detectGitHubRepository(root);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
note(`${basename(root)}'s origin isn't on GitHub, so there's no agent conversation to open here.`);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
let target;
|
|
48
|
+
try {
|
|
49
|
+
target = await resolveTarget(input.server);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (input.server !== undefined)
|
|
53
|
+
throw error;
|
|
54
|
+
note(describeError(error));
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const { remote } = target;
|
|
58
|
+
const key = { place: remote.name, repository, root };
|
|
59
|
+
const file = input.sessionsFile ?? sessionsPath();
|
|
60
|
+
const autopilot = await (input.autopilot ?? findAutopilot)();
|
|
61
|
+
// Asked while the tunnel opens; it only matters right before launching.
|
|
62
|
+
const ready = autopilot ? (input.probe ?? supportsAttachRemote)(autopilot).catch(() => false) : Promise.resolve(false);
|
|
63
|
+
const missing = () => {
|
|
64
|
+
io.err(` ${c.red("✗")} The Autopilot that comes with dl ${autopilot ? "is too old to open a session" : "isn't installed"}. Update: ${c.bold(UPDATE)}\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
};
|
|
67
|
+
// Ctrl-C (or a hang-up) before Autopilot starts stops dl, closing the tunnel on the way out;
|
|
68
|
+
// once it runs, Autopilot handles Ctrl-C itself and dl only passes on SIGTERM/SIGHUP.
|
|
69
|
+
const signals = input.signals ?? process;
|
|
70
|
+
let child;
|
|
71
|
+
let interrupt = () => { };
|
|
72
|
+
const interrupted = new Promise((_, reject) => { interrupt = () => reject(new Interrupted()); });
|
|
73
|
+
interrupted.catch(() => { });
|
|
74
|
+
const handlers = FORWARDED.map((name) => {
|
|
75
|
+
const handler = () => {
|
|
76
|
+
if (!child)
|
|
77
|
+
interrupt();
|
|
78
|
+
else if (name !== "SIGINT")
|
|
79
|
+
child.kill(name);
|
|
80
|
+
};
|
|
81
|
+
signals.on(name, handler);
|
|
82
|
+
return [name, handler];
|
|
83
|
+
});
|
|
84
|
+
const step = (work) => Promise.race([work, interrupted]);
|
|
85
|
+
try {
|
|
86
|
+
return await withConnections(input.open ?? openRemoteForSession, async (connections) => {
|
|
87
|
+
const client = await step(connections.client(remote));
|
|
88
|
+
let remembered = input.fresh ? undefined : await findSession(key, file);
|
|
89
|
+
if (remembered) {
|
|
90
|
+
const alive = await step(client.getConversation(remembered.conversationId).then((conversation) => conversation.status !== "destroyed", (error) => {
|
|
91
|
+
if (error instanceof RemoteApiError && (error.status === 404 || error.status === 410))
|
|
92
|
+
return false;
|
|
93
|
+
throw error;
|
|
94
|
+
}));
|
|
95
|
+
if (!alive) {
|
|
96
|
+
await forgetSession(key, file);
|
|
97
|
+
note(`The last conversation for ${repository} on ${remote.name} is gone; starting a new one.`);
|
|
98
|
+
remembered = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const scratch = await mkdtemp(join(tmpdir(), "dl-session-"));
|
|
102
|
+
try {
|
|
103
|
+
const stateFile = join(scratch, "state.json");
|
|
104
|
+
const args = ["attach-remote", "--state-file", stateFile, "--title", `${remote.name} · ${repository}`];
|
|
105
|
+
let detail;
|
|
106
|
+
if (remembered) {
|
|
107
|
+
args.push("--conversation", remembered.conversationId);
|
|
108
|
+
detail = remembered.conversationId.slice(0, 8);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
let snapshot;
|
|
112
|
+
try {
|
|
113
|
+
snapshot = await step(snapshotWorkingTree(root));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof Interrupted)
|
|
117
|
+
throw error;
|
|
118
|
+
note(`${describeError(error)}.`.replace(/\.\.$/, "."));
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const [workspaces, models, preferred] = await step(Promise.all([
|
|
122
|
+
client.workspaces(),
|
|
123
|
+
client.models(),
|
|
124
|
+
input.model ? Promise.resolve(undefined) : (input.autopilotModel ?? (() => readAutopilotModel()))(),
|
|
125
|
+
]));
|
|
126
|
+
const workspace = workspaces[0];
|
|
127
|
+
if (!workspace)
|
|
128
|
+
throw new Error(`${remote.name} has no workspace this laptop can use`);
|
|
129
|
+
const model = pickModel(models, input.model, preferred);
|
|
130
|
+
args.push("--workspace", workspace.id, "--repo", repository, "--model", model, "--base-commit", snapshot.baseCommit);
|
|
131
|
+
if (snapshot.patch) {
|
|
132
|
+
const patchFile = join(scratch, "changes.patch");
|
|
133
|
+
await writeFile(patchFile, snapshot.patch, { mode: 0o600 });
|
|
134
|
+
args.push("--patch-file", patchFile);
|
|
135
|
+
}
|
|
136
|
+
const changes = snapshot.files.length ? ` + ${snapshot.files.length} local change${snapshot.files.length === 1 ? "" : "s"}` : "";
|
|
137
|
+
detail = `new conversation from ${snapshot.baseCommit.slice(0, 7)}${changes}, ${model}`;
|
|
138
|
+
}
|
|
139
|
+
if (!autopilot || !(await step(ready)))
|
|
140
|
+
return missing();
|
|
141
|
+
// `dl new`: the old conversation keeps running on the place (dl runs lists it).
|
|
142
|
+
if (input.fresh)
|
|
143
|
+
await forgetSession(key, file);
|
|
144
|
+
io.err(`\n ${c.cyan("·")} Connecting to ${c.bold(remote.name)} · ${repository} … ${c.dim(`(${detail})`)}\n`);
|
|
145
|
+
// The token goes in the environment only: argv is visible to every user on the machine.
|
|
146
|
+
const env = { ...process.env, AUTOPILOT_REMOTE_URL: client.baseUrl, AUTOPILOT_REMOTE_TOKEN: remote.token };
|
|
147
|
+
let saved = remembered?.conversationId;
|
|
148
|
+
const save = async () => {
|
|
149
|
+
const state = await readState(stateFile);
|
|
150
|
+
if (!state || state.conversationId === saved)
|
|
151
|
+
return;
|
|
152
|
+
saved = state.conversationId;
|
|
153
|
+
await rememberSession({ ...key, conversationId: state.conversationId, ...(state.branch ? { branch: state.branch } : {}) }, file);
|
|
154
|
+
};
|
|
155
|
+
// Remember it as soon as it exists, so a closed terminal doesn't lose it.
|
|
156
|
+
const poll = setInterval(() => { void save().catch(() => { }); }, 500);
|
|
157
|
+
poll.unref?.();
|
|
158
|
+
const outcome = await new Promise((resolve) => {
|
|
159
|
+
const started = (input.spawn ?? realSpawn)(autopilot.command, [...autopilot.args, ...args], env);
|
|
160
|
+
child = started;
|
|
161
|
+
started.on("exit", (code) => resolve({ code }));
|
|
162
|
+
started.on("error", (error) => resolve(error));
|
|
163
|
+
}).finally(() => clearInterval(poll));
|
|
164
|
+
await save().catch(() => { });
|
|
165
|
+
if (outcome instanceof Error)
|
|
166
|
+
return missing();
|
|
167
|
+
if (outcome.code === 2)
|
|
168
|
+
return missing();
|
|
169
|
+
if (outcome.code !== 0)
|
|
170
|
+
return 1;
|
|
171
|
+
await recordLastUsed(remote.name).catch(() => undefined);
|
|
172
|
+
io.err(`\n It keeps running on ${c.bold(remote.name)}. Type ${c.bold("dl")} here to come back, or ${c.bold("dl runs")}.\n\n`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await rm(scratch, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (!(error instanceof Interrupted))
|
|
182
|
+
throw error;
|
|
183
|
+
io.err("\n");
|
|
184
|
+
return 130;
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
for (const [name, handler] of handlers)
|
|
188
|
+
signals.off(name, handler);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** What `attach-remote --state-file` wrote, once the conversation exists. */
|
|
192
|
+
async function readState(path) {
|
|
193
|
+
const raw = await readFile(path, "utf8").catch(() => undefined);
|
|
194
|
+
if (!raw)
|
|
195
|
+
return undefined;
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(raw);
|
|
198
|
+
if (typeof parsed.conversationId !== "string" || !parsed.conversationId)
|
|
199
|
+
return undefined;
|
|
200
|
+
return { conversationId: parsed.conversationId, ...(typeof parsed.branch === "string" && parsed.branch ? { branch: parsed.branch } : {}) };
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined; // still being written
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function gitRoot(cwd) {
|
|
207
|
+
try {
|
|
208
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
209
|
+
return stdout.trim() || undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The Autopilot installed with dl (run with this Node), else an `autopilot` on PATH. */
|
|
216
|
+
export async function findAutopilot() {
|
|
217
|
+
const bundled = bundledAutopilot();
|
|
218
|
+
if (bundled)
|
|
219
|
+
return { command: process.execPath, args: [bundled] };
|
|
220
|
+
const onPath = await autopilotOnPath();
|
|
221
|
+
return onPath ? { command: onPath, args: [] } : undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* autopilot-ai's bin script, found from where Node resolves the package for dl. Its exports
|
|
225
|
+
* don't include package.json, so the entry point is resolved and its package root found above it.
|
|
226
|
+
*/
|
|
227
|
+
export function bundledAutopilot(resolve = (specifier) => import.meta.resolve(specifier)) {
|
|
228
|
+
for (const specifier of ["autopilot-ai/package.json", "autopilot-ai"]) {
|
|
229
|
+
let dir;
|
|
230
|
+
try {
|
|
231
|
+
dir = dirname(fileURLToPath(resolve(specifier)));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (;;) {
|
|
237
|
+
const manifest = join(dir, "package.json");
|
|
238
|
+
if (existsSync(manifest)) {
|
|
239
|
+
try {
|
|
240
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
241
|
+
if (pkg.name === "autopilot-ai") {
|
|
242
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.autopilot;
|
|
243
|
+
const path = typeof bin === "string" ? resolvePath(dir, bin) : undefined;
|
|
244
|
+
return path && existsSync(path) ? path : undefined;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// not a readable manifest; keep looking up
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const parent = dirname(dir);
|
|
252
|
+
if (parent === dir)
|
|
253
|
+
break;
|
|
254
|
+
dir = parent;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
export async function autopilotOnPath(env = process.env) {
|
|
260
|
+
for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
261
|
+
const candidate = join(dir, "autopilot");
|
|
262
|
+
if (await access(candidate, constants.X_OK).then(() => true, () => false))
|
|
263
|
+
return candidate;
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
/** An Autopilot that knows `attach-remote` lists its options (an older one prints its general help). */
|
|
268
|
+
export async function supportsAttachRemote(autopilot) {
|
|
269
|
+
try {
|
|
270
|
+
const { stdout, stderr } = await execFileAsync(autopilot.command, [...autopilot.args, "attach-remote", "--help"], {
|
|
271
|
+
timeout: 30_000,
|
|
272
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
273
|
+
});
|
|
274
|
+
return `${stdout}${stderr}`.includes("--state-file");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/dist/laptop/setup.js
CHANGED
|
@@ -3,21 +3,23 @@ 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
12
|
import { openRemote, withConnections } from "./tunnel.js";
|
|
13
13
|
const RUN_EXAMPLE = 'dl run "fix the flaky checkout test"';
|
|
14
|
+
const PLACES = [
|
|
15
|
+
{ value: "local", label: "On this computer", detail: "start now · nothing to rent · pauses when this computer sleeps" },
|
|
16
|
+
{ value: "server", label: "On my own server", detail: "keeps going when your laptop is closed · needs SSH access" },
|
|
17
|
+
];
|
|
14
18
|
export async function setupWizard(deps) {
|
|
15
19
|
const c = palette(deps.color);
|
|
16
20
|
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
21
|
const ui = deps.newUi();
|
|
20
|
-
let where = await
|
|
22
|
+
let where = (await deps.prompter.select(ui.prompt("Where"), PLACES, 0)).value;
|
|
21
23
|
ui.answered("Where", where === "local" ? "On this computer" : "On my own server");
|
|
22
24
|
if (where === "local" && !deps.localSetup) {
|
|
23
25
|
ui.line("info", "On this computer", "coming soon");
|
|
@@ -30,11 +32,15 @@ export async function setupWizard(deps) {
|
|
|
30
32
|
}
|
|
31
33
|
if (where === "local") {
|
|
32
34
|
const keysUi = deps.newUi();
|
|
33
|
-
const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources });
|
|
35
|
+
const answers = await gatherKeys(["github", "openrouter"], { ui: keysUi, prompter: deps.prompter, sources: deps.sources, place: "this computer" });
|
|
34
36
|
keysUi.finish();
|
|
35
|
-
const remote = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const { remote, capacity } = await deps.localSetup({ ui: deps.newUi(), prompter: deps.prompter, answers: { openrouterKey: answers.openrouterKey, githubToken: answers.githubToken } });
|
|
38
|
+
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
39
|
+
const isNew = !(await loadRemotes(remotesFile)).some((entry) => entry.name === remote.name);
|
|
40
|
+
await saveRemote(remote, remotesFile);
|
|
41
|
+
if (isNew)
|
|
42
|
+
await chooseDefault(deps, remote, remotesFile);
|
|
43
|
+
finishScreen(deps.newUi(), capacity);
|
|
38
44
|
return remote;
|
|
39
45
|
}
|
|
40
46
|
return setUpServer(deps);
|
|
@@ -61,7 +67,8 @@ async function setUpServer(deps) {
|
|
|
61
67
|
// 4. A credential for this laptop, checked through the tunnel every command will use. Each
|
|
62
68
|
// `dl pair` makes a new credential on the server, so a re-run keeps the saved one if it works.
|
|
63
69
|
const remotesFile = deps.remotesFile ?? remotesPath();
|
|
64
|
-
const
|
|
70
|
+
const known = await loadRemotes(remotesFile);
|
|
71
|
+
const saved = known.find((entry) => sameServer(entry, access));
|
|
65
72
|
let remote;
|
|
66
73
|
if (saved && await stillWorks({ ...saved, ssh: access }, deps.open))
|
|
67
74
|
remote = { ...saved, ssh: access };
|
|
@@ -91,25 +98,25 @@ async function setUpServer(deps) {
|
|
|
91
98
|
await saveRemote(remote, remotesFile);
|
|
92
99
|
connectedUi.line("done", "This laptop", `connected as ${remote.name}`);
|
|
93
100
|
connectedUi.finish();
|
|
101
|
+
if (!known.some((entry) => entry.name === paired.name))
|
|
102
|
+
await chooseDefault(deps, remote, remotesFile);
|
|
94
103
|
finishScreen(deps.newUi(), capacity);
|
|
95
104
|
return remote;
|
|
96
105
|
}
|
|
97
106
|
async function chooseServer(deps, hosts) {
|
|
98
|
-
const c = palette(deps.color);
|
|
99
107
|
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";
|
|
108
|
+
deps.write("\n");
|
|
109
|
+
const example = "user@host[:port], e.g. root@203.0.113.10";
|
|
110
|
+
const options = [
|
|
111
|
+
...hosts.map((host, index) => ({ value: String(index), label: host.alias, detail: describeHost(host) })),
|
|
112
|
+
{ value: "another", label: "Another server", input: { prompt: ui.prompt("Server address", example), placeholder: example } },
|
|
113
|
+
];
|
|
109
114
|
for (let attempt = 1;; attempt++) {
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
115
|
+
// Without saved hosts, a one-item list would only be in the way: just ask for the address.
|
|
116
|
+
const picked = hosts.length > 0
|
|
117
|
+
? await deps.prompter.select(ui.prompt("Server"), options, 0)
|
|
118
|
+
: { value: "another", text: (await deps.prompter.text(ui.prompt("Server", example))).trim() };
|
|
119
|
+
const host = picked.value === "another" ? undefined : hosts[Number(picked.value)];
|
|
113
120
|
if (host) {
|
|
114
121
|
ui.answered("SSH", host.alias);
|
|
115
122
|
return {
|
|
@@ -118,12 +125,8 @@ async function chooseServer(deps, hosts) {
|
|
|
118
125
|
display: `${host.user ? `${host.user}@` : ""}${host.hostName ?? host.alias}${host.port && host.port !== 22 ? `:${host.port}` : ""}`,
|
|
119
126
|
};
|
|
120
127
|
}
|
|
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
128
|
try {
|
|
126
|
-
const access = parseTarget(
|
|
129
|
+
const access = parseTarget(picked.text ?? "");
|
|
127
130
|
ui.answered("SSH", access.target);
|
|
128
131
|
return { access, display: `${access.target}${access.port ? `:${access.port}` : ""}` };
|
|
129
132
|
}
|
|
@@ -134,6 +137,35 @@ async function chooseServer(deps, hosts) {
|
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* With two or more places set up, where `dl run` sends tasks unless told otherwise. Asked
|
|
142
|
+
* only when setup adds a place, with the new one preselected; the first place becomes the
|
|
143
|
+
* default by itself (saveRemote).
|
|
144
|
+
*/
|
|
145
|
+
async function chooseDefault(deps, added, remotesFile) {
|
|
146
|
+
const { remotes } = await loadRemotesFile(remotesFile);
|
|
147
|
+
if (remotes.length < 2)
|
|
148
|
+
return;
|
|
149
|
+
const ui = deps.newUi();
|
|
150
|
+
deps.write("\n");
|
|
151
|
+
const options = remotes.map((remote) => ({ value: remote.name, label: remote.name, detail: describePlace(remote) }));
|
|
152
|
+
const initial = Math.max(0, remotes.findIndex((remote) => remote.name === added.name));
|
|
153
|
+
const { value } = await deps.prompter.select(ui.prompt("Where should tasks go by default?"), options, initial);
|
|
154
|
+
await setDefaultRemote(value, remotesFile);
|
|
155
|
+
ui.answered("Default", value);
|
|
156
|
+
ui.finish();
|
|
157
|
+
}
|
|
158
|
+
function describePlace(remote) {
|
|
159
|
+
if (remote.ssh)
|
|
160
|
+
return `server · ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}`;
|
|
161
|
+
try {
|
|
162
|
+
const host = new URL(remote.url).hostname;
|
|
163
|
+
return host === "127.0.0.1" || host === "localhost" ? "this computer" : `server · ${host}`;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return remote.url;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
137
169
|
/**
|
|
138
170
|
* Connects and checks the server. If it refuses the default keys (ssh's own: the agent and
|
|
139
171
|
* ~/.ssh/id_*), each key pair in ~/.ssh is tried in turn with -i, without asking: auth
|
|
@@ -246,25 +278,13 @@ export function serverLabel(text) {
|
|
|
246
278
|
}
|
|
247
279
|
function finishScreen(ui, capacity) {
|
|
248
280
|
ui.ready(capacity ? `Ready · fits ~${capacity} agents at once (estimate)` : "Ready", [
|
|
249
|
-
{ label: "
|
|
250
|
-
{ label: "
|
|
251
|
-
]);
|
|
281
|
+
{ label: "Start a session: cd into a GitHub repository, then type", value: "dl" },
|
|
282
|
+
{ label: "Or hand off a one-off task", value: RUN_EXAMPLE },
|
|
283
|
+
], { footer: "dl status shows where agents run and what they're doing." });
|
|
252
284
|
}
|
|
253
285
|
function laptopName(name) {
|
|
254
286
|
return name.replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
|
255
287
|
}
|
|
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
288
|
/** `dl setup` with the real terminal, ssh, gh, and filesystem. */
|
|
269
289
|
export async function setup(options) {
|
|
270
290
|
const { io, terminal } = options;
|
|
@@ -277,25 +297,34 @@ export async function setup(options) {
|
|
|
277
297
|
identityFile = resolve(options.identityFile.replace(/^~(?=$|\/)/, home).replace(/\.pub$/, ""));
|
|
278
298
|
await fileAccess(identityFile).catch(() => { throw new Error(`No SSH key at ${options.identityFile} (--identity takes the private key, e.g. ~/.ssh/id_ed25519)`); });
|
|
279
299
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
+
try {
|
|
301
|
+
await setupWizard({
|
|
302
|
+
newUi: () => terminal ? new TerminalUi(io.out, undefined, color) : new PlainUi(io.out),
|
|
303
|
+
prompter: new TtyPrompter({ color }),
|
|
304
|
+
write: io.out,
|
|
305
|
+
color,
|
|
306
|
+
ssh: realSshExec,
|
|
307
|
+
open: openRemote,
|
|
308
|
+
sources: {
|
|
309
|
+
env: process.env,
|
|
310
|
+
home,
|
|
311
|
+
readFile: (path) => readFile(path, "utf8").catch(() => undefined),
|
|
312
|
+
listDir: (path) => readdir(path).catch(() => []),
|
|
313
|
+
gh: () => execFileAsync("gh", ["auth", "token"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || undefined, () => undefined),
|
|
314
|
+
fetch: globalThis.fetch,
|
|
315
|
+
},
|
|
316
|
+
hostname: hostname(),
|
|
317
|
+
installUrl: process.env.DL_INSTALL_URL || INSTALL_URL,
|
|
318
|
+
...(options.localSetup ? { localSetup: options.localSetup } : {}),
|
|
319
|
+
...(identityFile ? { identityFile } : {}),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (!(error instanceof PromptCancelled))
|
|
324
|
+
throw error;
|
|
325
|
+
// Esc / Ctrl-C at a question: the terminal is restored; say so quietly and stop.
|
|
326
|
+
io.out(` ${palette(color).dim("Cancelled. Run")} dl setup ${palette(color).dim("when you're ready.")}\n\n`);
|
|
327
|
+
return 130;
|
|
328
|
+
}
|
|
300
329
|
return 0;
|
|
301
330
|
}
|
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
|
/**
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "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
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://directionallylocal.com",
|
|
@@ -35,5 +35,8 @@
|
|
|
35
35
|
"@types/node": "^22.10.0",
|
|
36
36
|
"tsx": "^4.19.2",
|
|
37
37
|
"typescript": "^5.7.2"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"autopilot-ai": "^2.7.0"
|
|
38
41
|
}
|
|
39
42
|
}
|