@hellix-ai/cubes 0.2.2 → 0.3.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/README.md +52 -2
- package/dist/api.js +44 -11
- package/dist/args.js +17 -5
- package/dist/cli.js +37 -1
- package/dist/conversationCmds.js +273 -0
- package/dist/form.js +309 -0
- package/dist/local.js +251 -0
- package/dist/prompt.js +117 -0
- package/dist/transfer.js +166 -0
- package/package.json +8 -2
package/dist/form.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// The New session form, in a terminal - the web dialog's questions, in the web dialog's terms.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ ASKED FOR: "it should ask me which org, and all the other session creation form stuff, and any
|
|
4
|
+
// future session creation feature must be supported also here".
|
|
5
|
+
//
|
|
6
|
+
// So this mirrors web/src/pages/SessionsPage.tsx `NewSessionDialog` field for field, with the same
|
|
7
|
+
// defaults and the same rules between fields:
|
|
8
|
+
// • a GROUP pre-selects its default repositories (until you pick some yourself - here, by asking
|
|
9
|
+
// the group first);
|
|
10
|
+
// • the ORG's model list, with its default model listed once and meaning "follow the default";
|
|
11
|
+
// • an AGENT that declares a model decides the model (two that disagree leave it to you), and
|
|
12
|
+
// exactly one agent puts `--agent "<name>"` into the extra CLI args, where you can see it;
|
|
13
|
+
// • the RUNNER preselected is the one the server would resolve, and the choice on screen is sent;
|
|
14
|
+
// • Remote Control defaults ON, as in the dialog.
|
|
15
|
+
// The server re-checks every one of these (the create route is the authority); this only asks.
|
|
16
|
+
//
|
|
17
|
+
// "ANY FUTURE FEATURE" is enforced, not hoped for: `SPEC_FIELDS` must name every field of the
|
|
18
|
+
// request, TypeScript refuses this file if `SessionSpec` gains one it does not, and
|
|
19
|
+
// test/form.test.ts reads the web's own `SessionSpec` (web/src/cubes/cubesApi.ts) and fails when
|
|
20
|
+
// the two disagree. A field the dialog grows therefore cannot ship without this form learning it.
|
|
21
|
+
import { CliError } from "./auth.js";
|
|
22
|
+
/** How this CLI fills each field of the request. EVERY key, so a new field is a compile error here
|
|
23
|
+
* until somebody decides how the terminal asks for it. */
|
|
24
|
+
export const SPEC_FIELDS = {
|
|
25
|
+
title: "asked (--title)",
|
|
26
|
+
repoIds: "asked (--repo, repeatable)",
|
|
27
|
+
groupId: "asked (--group)",
|
|
28
|
+
cubeDefId: "asked (--cube)",
|
|
29
|
+
cubeInputs: "asked per cube input (--input name=value)",
|
|
30
|
+
agentNames: "asked (--agent, repeatable)",
|
|
31
|
+
model: "asked (--model)",
|
|
32
|
+
runnerId: "asked (--runner)",
|
|
33
|
+
rcEnabled: "asked (--rc / --no-rc)",
|
|
34
|
+
cliArgs: "asked (--cli-args)",
|
|
35
|
+
packageId: "--package (the dialog does not offer it)",
|
|
36
|
+
mcpServers: "--mcp-config FILE (the dialog does not offer it)",
|
|
37
|
+
hooks: "--hooks FILE (the dialog does not offer it)",
|
|
38
|
+
repoId: "superseded by repoIds; never sent",
|
|
39
|
+
importId: "the uploaded conversation (cubes import)",
|
|
40
|
+
};
|
|
41
|
+
export const TITLE_MAX = 80;
|
|
42
|
+
/** web/src/cubes/agentCliArg.ts, the same text: one agent's flag. */
|
|
43
|
+
export function agentFlag(name) {
|
|
44
|
+
return `--agent "${name}"`;
|
|
45
|
+
}
|
|
46
|
+
/** web/src/cubes/agentCliArg.ts `withAgentFlag`: `args` with its `--agent` set to `name`, or
|
|
47
|
+
* removed when `name` is null; the rest of the field is left alone. */
|
|
48
|
+
export function withAgentFlag(args, name) {
|
|
49
|
+
const stripped = args
|
|
50
|
+
.replace(/(^|\s)--agent(?:=(?:"[^"]*"|'[^']*'|\S*)|\s+(?:"[^"]*"|'[^']*'|[^\s-]\S*)?)?(?=\s|$)/g, "$1")
|
|
51
|
+
.replace(/\s+/g, " ")
|
|
52
|
+
.trim();
|
|
53
|
+
if (!name)
|
|
54
|
+
return stripped;
|
|
55
|
+
return stripped ? `${stripped} ${agentFlag(name)}` : agentFlag(name);
|
|
56
|
+
}
|
|
57
|
+
/** web/src/cubes/agentModelLock.ts: the model the picked agents decide, if they agree on one.
|
|
58
|
+
* `inherit` and blank are "no opinion"; strings are compared exactly. */
|
|
59
|
+
export function agentModelLock(picks) {
|
|
60
|
+
const order = [];
|
|
61
|
+
const by = new Map();
|
|
62
|
+
for (const p of picks) {
|
|
63
|
+
const v = (p.model ?? "").trim();
|
|
64
|
+
if (!v || v.toLowerCase() === "inherit")
|
|
65
|
+
continue;
|
|
66
|
+
const seen = by.get(v);
|
|
67
|
+
if (seen)
|
|
68
|
+
seen.push(p.name);
|
|
69
|
+
else {
|
|
70
|
+
by.set(v, [p.name]);
|
|
71
|
+
order.push(v);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (order.length === 0)
|
|
75
|
+
return { kind: "free" };
|
|
76
|
+
if (order.length === 1)
|
|
77
|
+
return { kind: "locked", model: order[0], by: by.get(order[0]) };
|
|
78
|
+
return { kind: "conflict", choices: order.map((m) => ({ model: m, by: by.get(m) })) };
|
|
79
|
+
}
|
|
80
|
+
const joinNames = (n) => (n.length <= 1 ? n[0] ?? "" : `${n.slice(0, -1).join(", ")} and ${n[n.length - 1]}`);
|
|
81
|
+
/** A thing named on the command line: its id, or its name (case-insensitively), exactly one. */
|
|
82
|
+
function byNameOrId(items, want, nameOf, what) {
|
|
83
|
+
const w = want.trim().toLowerCase();
|
|
84
|
+
const hit = items.filter((t) => t.id.toLowerCase() === w);
|
|
85
|
+
const named = hit.length ? hit : items.filter((t) => nameOf(t).some((n) => n.toLowerCase() === w));
|
|
86
|
+
if (named.length === 1)
|
|
87
|
+
return named[0];
|
|
88
|
+
if (named.length > 1)
|
|
89
|
+
throw new CliError(`"${want}" names ${named.length} ${what}s - use its id`);
|
|
90
|
+
const known = items.slice(0, 12).map((t) => nameOf(t)[0]).join(", ");
|
|
91
|
+
throw new CliError(`no ${what} "${want}"${items.length ? ` (there is: ${known}${items.length > 12 ? ", ..." : ""})` : ` - this org has none`}`);
|
|
92
|
+
}
|
|
93
|
+
const quiet = (p) => p.catch(() => null);
|
|
94
|
+
/**
|
|
95
|
+
* Ask everything the New session dialog asks, take what the command line already answered, and return
|
|
96
|
+
* the request. `ask` is the terminal (or, for --yes, a prompter that takes every default).
|
|
97
|
+
*/
|
|
98
|
+
export async function sessionForm(api, ask, flags, ctx = {}) {
|
|
99
|
+
// Everything the dialog reads on open, at once. A list that cannot be read omits its question,
|
|
100
|
+
// exactly as the dialog omits a picker it could not fill - except repositories, where "could not
|
|
101
|
+
// ask" must not read as "you have none".
|
|
102
|
+
const [repos, groupsRes, models, runnersRes, cubes, agentRepo] = await Promise.all([
|
|
103
|
+
quiet(api.get("/scm/repos")),
|
|
104
|
+
quiet(api.get("/session-groups")),
|
|
105
|
+
quiet(api.get("/org/models")),
|
|
106
|
+
quiet(api.get("/org/runners")),
|
|
107
|
+
quiet(api.get("/node-defs")),
|
|
108
|
+
quiet(api.get("/scm/agent-repo")),
|
|
109
|
+
]);
|
|
110
|
+
const groups = groupsRes?.groups ?? [];
|
|
111
|
+
const runners = (runnersRes?.runners ?? []).filter((r) => r.enabled);
|
|
112
|
+
// ── title ──
|
|
113
|
+
let title = flags.title?.trim() ?? "";
|
|
114
|
+
const suggested = (ctx.suggestedTitle ?? "").replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
|
|
115
|
+
while (!title) {
|
|
116
|
+
title = (await ask.text("Title", suggested || undefined)).trim();
|
|
117
|
+
if (!title)
|
|
118
|
+
ask.note(" a session needs a title");
|
|
119
|
+
}
|
|
120
|
+
if (title.length > TITLE_MAX)
|
|
121
|
+
throw new CliError(`the title is ${title.length} characters; at most ${TITLE_MAX}`);
|
|
122
|
+
// ── group (asked before the repositories, because it pre-selects them) ──
|
|
123
|
+
let group = null;
|
|
124
|
+
if (flags.group)
|
|
125
|
+
group = byNameOrId(groups, flags.group, (g) => [g.name], "group");
|
|
126
|
+
else if (groups.length) {
|
|
127
|
+
group = await ask.select("Group", [
|
|
128
|
+
{ label: "No group", value: null },
|
|
129
|
+
...groups.map((g) => ({ label: g.name, value: g, hint: g.repo_ids?.length ? `${g.repo_ids.length} default repo${g.repo_ids.length === 1 ? "" : "s"}` : undefined })),
|
|
130
|
+
], 0);
|
|
131
|
+
}
|
|
132
|
+
// ── repositories (optional) ──
|
|
133
|
+
let repoIds = [];
|
|
134
|
+
if (flags.repos?.length) {
|
|
135
|
+
if (!repos)
|
|
136
|
+
throw new CliError("could not read this org's repositories to look those up");
|
|
137
|
+
repoIds = [...new Set(flags.repos.map((r) => byNameOrId(repos, r, (x) => [x.full_name, x.full_name.split("/").pop() ?? ""], "repository").id))];
|
|
138
|
+
}
|
|
139
|
+
else if (repos === null) {
|
|
140
|
+
repoIds = group?.repo_ids ?? [];
|
|
141
|
+
ask.note(`Couldn't load your repositories. This is not the same as having none${repoIds.length ? " - the group's defaults will still be checked out" : ""}.`);
|
|
142
|
+
}
|
|
143
|
+
else if (!repos.length) {
|
|
144
|
+
ask.note("No repositories connected - this session will start in an empty working directory.");
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const hints = new Set((ctx.repoHints ?? []).map((h) => h.toLowerCase()));
|
|
148
|
+
const defaults = new Set(group?.repo_ids ?? []);
|
|
149
|
+
for (const r of repos)
|
|
150
|
+
if (hints.has(r.full_name.toLowerCase()))
|
|
151
|
+
defaults.add(r.id);
|
|
152
|
+
const choices = repos.map((r) => ({
|
|
153
|
+
label: r.full_name, value: r.id,
|
|
154
|
+
hint: hints.has(r.full_name.toLowerCase()) ? "this conversation's checkout" : undefined,
|
|
155
|
+
}));
|
|
156
|
+
repoIds = await ask.multi("Repositories (each is checked out into its own folder; none = an empty directory)", choices, repos.flatMap((r, i) => (defaults.has(r.id) ? [i] : [])));
|
|
157
|
+
}
|
|
158
|
+
// ── start from a cube (optional) ──
|
|
159
|
+
let cube = null;
|
|
160
|
+
{
|
|
161
|
+
let pick = null;
|
|
162
|
+
if (flags.cube) {
|
|
163
|
+
if (!cubes)
|
|
164
|
+
throw new CliError("could not read this org's cube library to look that up");
|
|
165
|
+
pick = byNameOrId(cubes, flags.cube, (c) => [c.name], "cube");
|
|
166
|
+
}
|
|
167
|
+
else if (cubes?.length) {
|
|
168
|
+
pick = await ask.select("Start from a cube (its prompt is the session's next turn)", [
|
|
169
|
+
{ label: "No cube", value: null },
|
|
170
|
+
...cubes.map((c) => ({ label: c.name, value: c, hint: c.description ? c.description.slice(0, 60) : undefined })),
|
|
171
|
+
], 0);
|
|
172
|
+
}
|
|
173
|
+
if (pick) {
|
|
174
|
+
const def = await api.get(`/node-defs/${encodeURIComponent(pick.id)}`);
|
|
175
|
+
const inputs = {};
|
|
176
|
+
for (const inp of def.inputs ?? []) {
|
|
177
|
+
const given = flags.inputs?.[inp.name];
|
|
178
|
+
inputs[inp.name] = given !== undefined ? given : await ask.text(` ${inp.title || inp.name}`, inp.default);
|
|
179
|
+
}
|
|
180
|
+
for (const k of Object.keys(flags.inputs ?? {})) {
|
|
181
|
+
if (!(def.inputs ?? []).some((i) => i.name === k))
|
|
182
|
+
throw new CliError(`the cube "${def.name}" has no input "${k}"`);
|
|
183
|
+
}
|
|
184
|
+
cube = { defId: def.id, name: def.name, inputs };
|
|
185
|
+
}
|
|
186
|
+
else if (flags.inputs && Object.keys(flags.inputs).length) {
|
|
187
|
+
throw new CliError("--input fills a cube's inputs; pass --cube too");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ── agents (optional; only when the org has an agents repository) ──
|
|
191
|
+
let agents = [];
|
|
192
|
+
{
|
|
193
|
+
const catalog = agentRepo?.repo
|
|
194
|
+
? await quiet(api.get(`/scm/repos/${encodeURIComponent(agentRepo.repo.repoId)}/agents`))
|
|
195
|
+
: null;
|
|
196
|
+
// A name declared by two files is a name nobody can pick - the dialog shows it disabled.
|
|
197
|
+
const count = new Map();
|
|
198
|
+
for (const a of catalog?.agents ?? [])
|
|
199
|
+
count.set(a.name, (count.get(a.name) ?? 0) + 1);
|
|
200
|
+
const pickable = (catalog?.agents ?? []).filter((a) => count.get(a.name) === 1);
|
|
201
|
+
if (flags.agents?.length) {
|
|
202
|
+
if (!catalog)
|
|
203
|
+
throw new CliError("this org has no agents repository to pick agents from");
|
|
204
|
+
agents = flags.agents.map((n) => {
|
|
205
|
+
if ((count.get(n) ?? 0) > 1)
|
|
206
|
+
throw new CliError(`the agent "${n}" is declared by more than one file in the agents repository`);
|
|
207
|
+
const a = pickable.find((x) => x.name === n);
|
|
208
|
+
if (!a)
|
|
209
|
+
throw new CliError(`no agent "${n}" (there is: ${pickable.map((x) => x.name).join(", ") || "none"})`);
|
|
210
|
+
return a;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else if (pickable.length) {
|
|
214
|
+
agents = await ask.multi("Agents it can delegate to", pickable.map((a) => ({
|
|
215
|
+
label: a.name, value: a,
|
|
216
|
+
hint: [a.model && a.model !== "inherit" ? a.model : "", (a.description ?? "").slice(0, 50)].filter(Boolean).join(" - ") || undefined,
|
|
217
|
+
})), []);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ── model ──
|
|
221
|
+
let model = null;
|
|
222
|
+
const lock = agentModelLock(agents);
|
|
223
|
+
if (lock.kind === "locked") {
|
|
224
|
+
if (flags.model && flags.model !== lock.model) {
|
|
225
|
+
throw new CliError(`${joinNames(lock.by)} declares ${lock.model}, so the session starts on it; drop --model or the agent`);
|
|
226
|
+
}
|
|
227
|
+
model = lock.model;
|
|
228
|
+
ask.note(`Model: ${lock.model} (set by ${joinNames(lock.by)})`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
if (lock.kind === "conflict") {
|
|
232
|
+
ask.note(`${lock.choices.map((c) => `${joinNames(c.by)} asks for ${c.model}`).join("; ")}. They disagree, so this stays your choice - each agent still runs on the model it declares.`);
|
|
233
|
+
}
|
|
234
|
+
if (flags.model) {
|
|
235
|
+
model = flags.model === "default" ? null : flags.model;
|
|
236
|
+
}
|
|
237
|
+
else if (models?.options?.length) {
|
|
238
|
+
const def = models.defaultModel ?? models.platformDefault ?? null;
|
|
239
|
+
const listed = !!def && models.options.some((m) => m.id === def);
|
|
240
|
+
// The default model listed ONCE: its own row, tagged default, meaning "follow the default".
|
|
241
|
+
const choices = listed
|
|
242
|
+
? models.options.map((m) => ({ label: m.label, value: m.id === def ? null : m.id, hint: m.id === def ? "default" : undefined }))
|
|
243
|
+
: [{ label: def ? def : "Default", value: null, hint: "default" }, ...models.options.map((m) => ({ label: m.label, value: m.id }))];
|
|
244
|
+
model = await ask.select("Model", choices, Math.max(0, choices.findIndex((c) => c.value === null)));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ── where it runs ──
|
|
248
|
+
let runnerId = null;
|
|
249
|
+
if (runners.length) {
|
|
250
|
+
const q = new URLSearchParams();
|
|
251
|
+
if (group)
|
|
252
|
+
q.set("groupId", group.id);
|
|
253
|
+
const resolved = await quiet(api.get(`/org/runners/resolve${q.size ? `?${q}` : ""}`));
|
|
254
|
+
if (resolved?.problem)
|
|
255
|
+
ask.note(`Runner: ${resolved.problem}`);
|
|
256
|
+
if (flags.runner)
|
|
257
|
+
runnerId = byNameOrId(runners, flags.runner, (r) => [r.name], "runner").id;
|
|
258
|
+
else {
|
|
259
|
+
const def = Math.max(0, runners.findIndex((r) => r.id === resolved?.runnerId));
|
|
260
|
+
const sourceHint = { group: "the group's", org: "the org default", only: "the only one", package: "the package's", folder: "the folder's" };
|
|
261
|
+
runnerId = await ask.select("Runner", runners.map((r) => ({
|
|
262
|
+
label: r.name, value: r.id,
|
|
263
|
+
hint: r.id === resolved?.runnerId ? sourceHint[resolved.source] ?? undefined : undefined,
|
|
264
|
+
})), def);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// ── Remote Control (on by default, as in the dialog) ──
|
|
268
|
+
const rcEnabled = flags.rc ?? await ask.confirm("Remote Control (run /login, then /rc, once it starts)", true);
|
|
269
|
+
// ── extra claude CLI args ──
|
|
270
|
+
const sole = agents.length === 1 ? agents[0].name : null;
|
|
271
|
+
let cliArgs = withAgentFlag(flags.cliArgs ?? "", sole);
|
|
272
|
+
if (flags.cliArgs === undefined)
|
|
273
|
+
cliArgs = (await ask.text("Extra claude CLI args", cliArgs || undefined)).trim();
|
|
274
|
+
const spec = {
|
|
275
|
+
title,
|
|
276
|
+
model,
|
|
277
|
+
repoIds,
|
|
278
|
+
cliArgs: cliArgs || null,
|
|
279
|
+
groupId: group?.id ?? null,
|
|
280
|
+
...(rcEnabled ? { rcEnabled: true } : {}),
|
|
281
|
+
...(cube ? { cubeDefId: cube.defId, cubeInputs: cube.inputs } : {}),
|
|
282
|
+
...(agents.length ? { agentNames: agents.map((a) => a.name) } : {}),
|
|
283
|
+
...(runnerId ? { runnerId } : {}),
|
|
284
|
+
...(flags.packageId ? { packageId: flags.packageId } : {}),
|
|
285
|
+
...(flags.mcpServers ? { mcpServers: flags.mcpServers } : {}),
|
|
286
|
+
...(flags.hooks ? { hooks: flags.hooks } : {}),
|
|
287
|
+
};
|
|
288
|
+
// What a person reads before saying yes, in names rather than ids.
|
|
289
|
+
const repoName = new Map((repos ?? []).map((r) => [r.id, r.full_name]));
|
|
290
|
+
const summary = [` Title ${title}`];
|
|
291
|
+
summary.push(` Repos ${repoIds.length ? repoIds.map((id) => repoName.get(id) ?? id).join(", ") : "none (an empty directory)"}`);
|
|
292
|
+
if (group)
|
|
293
|
+
summary.push(` Group ${group.name}`);
|
|
294
|
+
if (cube)
|
|
295
|
+
summary.push(` Cube ${cube.name}`);
|
|
296
|
+
if (agents.length)
|
|
297
|
+
summary.push(` Agents ${agents.map((a) => a.name).join(", ")}`);
|
|
298
|
+
const labelOf = (id) => models?.options?.find((m) => m.id === id)?.label ?? id;
|
|
299
|
+
const orgDefault = models?.defaultModel ?? models?.platformDefault ?? null;
|
|
300
|
+
summary.push(` Model ${model ? labelOf(model) : orgDefault ? `${labelOf(orgDefault)} (the org default)` : "the org default"}`);
|
|
301
|
+
if (runnerId)
|
|
302
|
+
summary.push(` Runner ${runners.find((r) => r.id === runnerId)?.name ?? runnerId}`);
|
|
303
|
+
summary.push(` Remote ${rcEnabled ? "on" : "off"}`);
|
|
304
|
+
if (cliArgs)
|
|
305
|
+
summary.push(` Args ${cliArgs}`);
|
|
306
|
+
if (flags.packageId)
|
|
307
|
+
summary.push(` Package ${flags.packageId}`);
|
|
308
|
+
return { spec, summary };
|
|
309
|
+
}
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The Claude Code conversations on THIS computer: finding one to import, and filing an exported one
|
|
2
|
+
// where `claude --resume` will find it.
|
|
3
|
+
//
|
|
4
|
+
// Claude Code keeps one JSONL per conversation at
|
|
5
|
+
//
|
|
6
|
+
// <config dir>/projects/<escaped cwd>/<conversation id>.jsonl
|
|
7
|
+
//
|
|
8
|
+
// where the config dir is $CLAUDE_CONFIG_DIR or ~/.claude, and the escaped cwd is the directory the
|
|
9
|
+
// conversation ran in with every character that is not a letter or a digit replaced by "-" (the same
|
|
10
|
+
// rule the Cubes server uses for a session's own transcripts). Subagent transcripts live in a
|
|
11
|
+
// directory beside the file and are not part of the conversation `--resume` reads, so they are neither
|
|
12
|
+
// listed nor carried.
|
|
13
|
+
//
|
|
14
|
+
// Nothing here parses a conversation beyond its first and last few kilobytes: enough to say where it
|
|
15
|
+
// ran and what it was about, so a person can tell one from another in a list.
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { mkdir, open, readdir, rename, stat, rm } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
20
|
+
import { CliError } from "./auth.js";
|
|
21
|
+
export function claudeConfigDir(env = process.env) {
|
|
22
|
+
return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
23
|
+
}
|
|
24
|
+
/** Claude Code's project-directory name for a working directory. */
|
|
25
|
+
export function escapeCwd(cwd) {
|
|
26
|
+
return cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
27
|
+
}
|
|
28
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
export const isConversationId = (s) => UUID.test(s);
|
|
30
|
+
/** Every conversation file, newest first, without reading inside any of them. */
|
|
31
|
+
export async function listConversationFiles(configDir) {
|
|
32
|
+
const root = join(configDir, "projects");
|
|
33
|
+
let dirs;
|
|
34
|
+
try {
|
|
35
|
+
dirs = await readdir(root);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const d of dirs) {
|
|
42
|
+
let names;
|
|
43
|
+
try {
|
|
44
|
+
names = await readdir(join(root, d));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const n of names) {
|
|
50
|
+
if (!n.endsWith(".jsonl"))
|
|
51
|
+
continue;
|
|
52
|
+
const id = n.slice(0, -".jsonl".length);
|
|
53
|
+
if (!UUID.test(id))
|
|
54
|
+
continue;
|
|
55
|
+
const path = join(root, d, n);
|
|
56
|
+
try {
|
|
57
|
+
const st = await stat(path);
|
|
58
|
+
if (!st.isFile() || st.size === 0)
|
|
59
|
+
continue;
|
|
60
|
+
out.push({ id, path, bytes: st.size, mtime: st.mtimeMs });
|
|
61
|
+
}
|
|
62
|
+
catch { /* vanished between the listing and the stat */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
66
|
+
}
|
|
67
|
+
const PEEK = 64 * 1024;
|
|
68
|
+
async function readWindow(path, from, len) {
|
|
69
|
+
const fh = await open(path, "r");
|
|
70
|
+
try {
|
|
71
|
+
const buf = Buffer.alloc(len);
|
|
72
|
+
const { bytesRead } = await fh.read(buf, 0, len, from);
|
|
73
|
+
return buf.subarray(0, bytesRead).toString("utf8");
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await fh.close().catch(() => { });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Lines that are whole JSON records. A window cuts its first and last line; those are dropped. */
|
|
80
|
+
function records(text, cutHead, cutTail) {
|
|
81
|
+
const lines = text.split("\n");
|
|
82
|
+
if (cutHead)
|
|
83
|
+
lines.shift();
|
|
84
|
+
if (cutTail)
|
|
85
|
+
lines.pop();
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const l of lines) {
|
|
88
|
+
if (!l)
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const r = JSON.parse(l);
|
|
92
|
+
if (r && typeof r === "object" && !Array.isArray(r))
|
|
93
|
+
out.push(r);
|
|
94
|
+
}
|
|
95
|
+
catch { /* a line still being written */ }
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** The text a person typed in a user record, or null for tool results, commands and meta turns. */
|
|
100
|
+
function typedText(rec) {
|
|
101
|
+
if (rec.type !== "user" || rec.isMeta === true || rec.isSidechain === true)
|
|
102
|
+
return null;
|
|
103
|
+
const content = rec.message?.content;
|
|
104
|
+
let text = null;
|
|
105
|
+
if (typeof content === "string")
|
|
106
|
+
text = content;
|
|
107
|
+
else if (Array.isArray(content)) {
|
|
108
|
+
const first = content.find((b) => b && typeof b === "object" && b.type === "text");
|
|
109
|
+
if (first && typeof first.text === "string")
|
|
110
|
+
text = first.text;
|
|
111
|
+
}
|
|
112
|
+
if (!text)
|
|
113
|
+
return null;
|
|
114
|
+
const t = text.trim();
|
|
115
|
+
// Slash commands and their output are recorded as tagged user turns; they are not what it was about.
|
|
116
|
+
if (!t || t.startsWith("<") || t.startsWith("Caveat:"))
|
|
117
|
+
return null;
|
|
118
|
+
return t.replace(/\s+/g, " ");
|
|
119
|
+
}
|
|
120
|
+
/** Read what a conversation says about itself from its head and its tail. */
|
|
121
|
+
export async function describeConversation(f) {
|
|
122
|
+
const head = records(await readWindow(f.path, 0, Math.min(PEEK, f.bytes)), false, f.bytes > PEEK);
|
|
123
|
+
const tailFrom = Math.max(0, f.bytes - PEEK);
|
|
124
|
+
const tail = tailFrom > 0 ? records(await readWindow(f.path, tailFrom, PEEK), true, false) : head;
|
|
125
|
+
let cwd = null;
|
|
126
|
+
let gitBranch = null;
|
|
127
|
+
let firstPrompt = null;
|
|
128
|
+
for (const r of head) {
|
|
129
|
+
if (!cwd && typeof r.cwd === "string")
|
|
130
|
+
cwd = r.cwd;
|
|
131
|
+
if (!gitBranch && typeof r.gitBranch === "string" && r.gitBranch)
|
|
132
|
+
gitBranch = r.gitBranch;
|
|
133
|
+
if (!firstPrompt)
|
|
134
|
+
firstPrompt = typedText(r);
|
|
135
|
+
if (cwd && firstPrompt)
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
// Titles are re-recorded as a conversation goes on; the LAST one of each kind is the current one,
|
|
139
|
+
// and a name the person chose outranks one Claude made up.
|
|
140
|
+
const last = {};
|
|
141
|
+
for (const r of [...head, ...tail]) {
|
|
142
|
+
if (r.type === "custom-title" && typeof r.customTitle === "string")
|
|
143
|
+
last.custom = r.customTitle;
|
|
144
|
+
else if (r.type === "agent-name" && typeof r.agentName === "string")
|
|
145
|
+
last.agent = r.agentName;
|
|
146
|
+
else if (r.type === "ai-title" && typeof r.aiTitle === "string")
|
|
147
|
+
last.ai = r.aiTitle;
|
|
148
|
+
else if (r.type === "summary" && typeof r.summary === "string")
|
|
149
|
+
last.summary = r.summary;
|
|
150
|
+
if (typeof r.cwd === "string")
|
|
151
|
+
cwd = r.cwd;
|
|
152
|
+
}
|
|
153
|
+
const title = (last.custom ?? last.agent ?? last.ai ?? last.summary ?? "").trim() || null;
|
|
154
|
+
return { ...f, cwd, gitBranch, title, firstPrompt };
|
|
155
|
+
}
|
|
156
|
+
/** The conversations worth offering, most recent first: those that ran in `cwd` ahead of the rest. */
|
|
157
|
+
export async function recentConversations(configDir, opts = {}) {
|
|
158
|
+
const limit = opts.limit ?? 20;
|
|
159
|
+
const files = await listConversationFiles(configDir);
|
|
160
|
+
const here = opts.cwd ? join(configDir, "projects", escapeCwd(resolve(opts.cwd))) : null;
|
|
161
|
+
const ordered = here
|
|
162
|
+
? [...files.filter((f) => f.path.startsWith(`${here}${sep}`)), ...files.filter((f) => !f.path.startsWith(`${here}${sep}`))]
|
|
163
|
+
: files;
|
|
164
|
+
return Promise.all(ordered.slice(0, limit).map(describeConversation));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Turn what someone typed into ONE conversation: a path to a .jsonl, its id, or the start of its id
|
|
168
|
+
* (4+ characters). Ambiguity is an error naming the candidates, never a guess.
|
|
169
|
+
*/
|
|
170
|
+
export async function resolveConversation(configDir, query) {
|
|
171
|
+
const q = query.trim();
|
|
172
|
+
if (!q)
|
|
173
|
+
throw new CliError("which conversation? pass its id, the start of it, or the path to its .jsonl");
|
|
174
|
+
if (q.endsWith(".jsonl") || q.includes("/") || q.includes("\\")) {
|
|
175
|
+
const path = resolve(q);
|
|
176
|
+
let st;
|
|
177
|
+
try {
|
|
178
|
+
st = await stat(path);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new CliError(`no such file: ${path}`);
|
|
182
|
+
}
|
|
183
|
+
const id = basename(path).replace(/\.jsonl$/, "");
|
|
184
|
+
if (!UUID.test(id))
|
|
185
|
+
throw new CliError(`${basename(path)} is not named like a Claude Code conversation (<uuid>.jsonl)`);
|
|
186
|
+
return describeConversation({ id, path, bytes: st.size, mtime: st.mtimeMs });
|
|
187
|
+
}
|
|
188
|
+
const files = await listConversationFiles(configDir);
|
|
189
|
+
const lower = q.toLowerCase();
|
|
190
|
+
const hits = files.filter((f) => f.id.toLowerCase() === lower);
|
|
191
|
+
const pre = hits.length ? hits : lower.length >= 4 ? files.filter((f) => f.id.toLowerCase().startsWith(lower)) : [];
|
|
192
|
+
if (pre.length === 1)
|
|
193
|
+
return describeConversation(pre[0]);
|
|
194
|
+
if (pre.length > 1) {
|
|
195
|
+
const ids = pre.slice(0, 8).map((f) => ` ${f.id} ${f.path}`).join("\n");
|
|
196
|
+
throw new CliError(`"${q}" matches ${pre.length} conversations:\n${ids}\nuse more of the id`);
|
|
197
|
+
}
|
|
198
|
+
throw new CliError(`no conversation matches "${q}" in ${join(configDir, "projects")}`);
|
|
199
|
+
}
|
|
200
|
+
/** Where `claude --resume <id>`, run in `cwd`, looks for a conversation. */
|
|
201
|
+
export function exportTarget(configDir, cwd, conversationId) {
|
|
202
|
+
return join(configDir, "projects", escapeCwd(resolve(cwd)), `${conversationId}.jsonl`);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Put a downloaded conversation in place, safely.
|
|
206
|
+
*
|
|
207
|
+
* ⚠ NEVER OVER LOCAL WORK. Exporting the same session twice is the ordinary case and simply brings the
|
|
208
|
+
* file up to date - but if the copy already here has turns the download does not (it was continued on
|
|
209
|
+
* this computer since the last export), replacing it would delete them. So an existing file is
|
|
210
|
+
* replaced only when it is a PREFIX of the download, unless `force`.
|
|
211
|
+
*/
|
|
212
|
+
export async function placeExport(downloaded, target, force) {
|
|
213
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
214
|
+
if (existsSync(target)) {
|
|
215
|
+
const [here, there] = await Promise.all([stat(target), stat(downloaded)]);
|
|
216
|
+
if (!force) {
|
|
217
|
+
const prefix = here.size <= there.size && await isPrefix(target, downloaded, here.size);
|
|
218
|
+
if (!prefix) {
|
|
219
|
+
await rm(downloaded, { force: true });
|
|
220
|
+
throw new CliError(`${target} already exists and has turns this export does not - it was continued on this computer. `
|
|
221
|
+
+ "Pass --force to replace it, or --dir to export it under another directory.");
|
|
222
|
+
}
|
|
223
|
+
if (here.size === there.size) {
|
|
224
|
+
await rm(downloaded, { force: true });
|
|
225
|
+
return "unchanged";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
await rename(downloaded, target);
|
|
229
|
+
return "updated";
|
|
230
|
+
}
|
|
231
|
+
await rename(downloaded, target);
|
|
232
|
+
return "created";
|
|
233
|
+
}
|
|
234
|
+
async function isPrefix(a, b, len) {
|
|
235
|
+
const [fa, fb] = await Promise.all([open(a, "r"), open(b, "r")]);
|
|
236
|
+
try {
|
|
237
|
+
const size = 1024 * 1024;
|
|
238
|
+
const ba = Buffer.alloc(size);
|
|
239
|
+
const bb = Buffer.alloc(size);
|
|
240
|
+
for (let at = 0; at < len; at += size) {
|
|
241
|
+
const n = Math.min(size, len - at);
|
|
242
|
+
const [ra, rb] = await Promise.all([fa.read(ba, 0, n, at), fb.read(bb, 0, n, at)]);
|
|
243
|
+
if (ra.bytesRead !== n || rb.bytesRead !== n || !ba.subarray(0, n).equals(bb.subarray(0, n)))
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
await Promise.all([fa.close(), fb.close()]).catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Asking questions in a terminal: line-based, numbered, Enter takes the default.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately not a full-screen picker. The session form runs before anything is attached, in
|
|
4
|
+
// whatever terminal the person has (Warp, a plain ssh, a CI log when piped), and a numbered list read
|
|
5
|
+
// with one line of input works in every one of them. Questions go to stderr so stdout stays clean.
|
|
6
|
+
//
|
|
7
|
+
// Behind an interface so the form can be driven by a script in tests.
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { CliError } from "./auth.js";
|
|
10
|
+
/** "1 3", "1,3", "2-4", "1, 3-5" -> zero-based indexes, or null when it does not parse. */
|
|
11
|
+
export function parseNumbers(answer, count) {
|
|
12
|
+
const out = new Set();
|
|
13
|
+
for (const part of answer.split(/[\s,]+/).filter(Boolean)) {
|
|
14
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
const a = Number(m[1]);
|
|
18
|
+
const b = m[2] ? Number(m[2]) : a;
|
|
19
|
+
if (a < 1 || b > count || a > b)
|
|
20
|
+
return null;
|
|
21
|
+
for (let i = a; i <= b; i++)
|
|
22
|
+
out.add(i - 1);
|
|
23
|
+
}
|
|
24
|
+
return [...out].sort((x, y) => x - y);
|
|
25
|
+
}
|
|
26
|
+
export function terminalPrompter(input = process.stdin, output = process.stderr, color = !process.env.NO_COLOR && !!output.isTTY) {
|
|
27
|
+
const dim = (t) => (color ? `\x1b[2m${t}\x1b[0m` : t);
|
|
28
|
+
const bold = (t) => (color ? `\x1b[1m${t}\x1b[0m` : t);
|
|
29
|
+
const ask = async (q) => {
|
|
30
|
+
const rl = createInterface({ input, output, terminal: input.isTTY ?? false });
|
|
31
|
+
// Ctrl-C at a question leaves the whole command, not just the question.
|
|
32
|
+
rl.on("SIGINT", () => { rl.close(); output.write("\n"); process.exit(130); });
|
|
33
|
+
try {
|
|
34
|
+
return (await rl.question(q)).trim();
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// Ctrl-D (end of input) at a question: nothing more is coming, so stop here, plainly.
|
|
38
|
+
if (e.code === "ABORT_ERR")
|
|
39
|
+
throw new CliError("cancelled - nothing was started");
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
rl.close();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const list = (choices, marked) => {
|
|
47
|
+
const w = String(choices.length).length;
|
|
48
|
+
choices.forEach((c, i) => {
|
|
49
|
+
const mark = marked(i) ? "*" : " ";
|
|
50
|
+
output.write(` ${mark} ${String(i + 1).padStart(w)}) ${c.label}${c.hint ? ` ${dim(c.hint)}` : ""}\n`);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
async text(question, def) {
|
|
55
|
+
const a = await ask(`${bold(question)}${def ? ` ${dim(`[${def}]`)}` : ""}: `);
|
|
56
|
+
return a || def || "";
|
|
57
|
+
},
|
|
58
|
+
async confirm(question, def) {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const a = (await ask(`${bold(question)} ${dim(def ? "[Y/n]" : "[y/N]")} `)).toLowerCase();
|
|
61
|
+
if (!a)
|
|
62
|
+
return def;
|
|
63
|
+
if (a === "y" || a === "yes")
|
|
64
|
+
return true;
|
|
65
|
+
if (a === "n" || a === "no")
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async select(question, choices, def = 0) {
|
|
70
|
+
if (!choices.length)
|
|
71
|
+
throw new CliError(`nothing to choose from for "${question}"`);
|
|
72
|
+
output.write(`${bold(question)}\n`);
|
|
73
|
+
list(choices, (i) => i === def);
|
|
74
|
+
for (;;) {
|
|
75
|
+
const a = await ask(` ${dim(`number, Enter for ${def + 1}`)}: `);
|
|
76
|
+
if (!a)
|
|
77
|
+
return choices[def].value;
|
|
78
|
+
const n = Number(a);
|
|
79
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length)
|
|
80
|
+
return choices[n - 1].value;
|
|
81
|
+
output.write(` ${dim(`pick 1-${choices.length}`)}\n`);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async multi(question, choices, defs) {
|
|
85
|
+
output.write(`${bold(question)}\n`);
|
|
86
|
+
list(choices, (i) => defs.includes(i));
|
|
87
|
+
for (;;) {
|
|
88
|
+
const a = await ask(` ${dim(`numbers like "1 3" or "2-4", - for none, Enter keeps ${defs.length ? "the * ones" : "none"}`)}: `);
|
|
89
|
+
if (!a)
|
|
90
|
+
return defs.map((i) => choices[i].value);
|
|
91
|
+
if (a === "-")
|
|
92
|
+
return [];
|
|
93
|
+
const picked = parseNumbers(a, choices.length);
|
|
94
|
+
if (picked)
|
|
95
|
+
return picked.map((i) => choices[i].value);
|
|
96
|
+
output.write(` ${dim(`numbers between 1 and ${choices.length}`)}\n`);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
note(line) {
|
|
100
|
+
output.write(`${dim(line)}\n`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** For `--yes` and for scripts: every question takes its default, and says so. */
|
|
105
|
+
export function defaultsPrompter(output = process.stderr) {
|
|
106
|
+
return {
|
|
107
|
+
text: async (_q, def) => def ?? "",
|
|
108
|
+
confirm: async (_q, def) => def,
|
|
109
|
+
select: async (q, choices, def = 0) => {
|
|
110
|
+
if (!choices.length)
|
|
111
|
+
throw new CliError(`nothing to choose from for "${q}"`);
|
|
112
|
+
return choices[def].value;
|
|
113
|
+
},
|
|
114
|
+
multi: async (_q, choices, defs) => defs.map((i) => choices[i].value),
|
|
115
|
+
note: (line) => { output.write(`${line}\n`); },
|
|
116
|
+
};
|
|
117
|
+
}
|