strom-research 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/assets/lang/cs.json +164 -7
- package/assets/lang/de.json +164 -7
- package/assets/plugins/README.md +8 -1
- package/assets/plugins/gates/README.md +1 -1
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/files.js +142 -99
- package/dist/agents/global.js +83 -6
- package/dist/agents/launch.js +12 -5
- package/dist/agents/profiles.js +13 -2
- package/dist/cli/main.js +17 -1
- package/dist/cli/menu-parts.js +140 -0
- package/dist/cli/menu-research.js +194 -0
- package/dist/cli/menu-settings.js +273 -0
- package/dist/cli/menu-waiting.js +79 -0
- package/dist/cli/menu.js +91 -97
- package/dist/cli/registry.js +1 -1
- package/dist/cli/ui.js +162 -6
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +2 -2
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/meta.js +6 -2
- package/dist/commands/research.js +55 -16
- package/dist/commands/session.js +15 -7
- package/dist/commands/setup.js +51 -2
- package/dist/commands/start.js +9 -4
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/config.js +7 -1
- package/dist/core/hooks.js +210 -0
- package/dist/core/install.js +3 -2
- package/dist/core/media.js +21 -7
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/shortcut.js +1 -4
- package/dist/core/tree.js +19 -2
- package/dist/core/which.js +18 -0
- package/dist/runners/claude.js +3 -0
- package/dist/runners/codex.js +14 -5
- package/dist/runners/grok.js +108 -0
- package/dist/runners/index.js +2 -0
- package/dist/runners/opencode.js +8 -4
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// What the guided menu and its submenus share: an item, the loop of a submenu,
|
|
2
|
+
// asking for a person, and the paths a person drops into the terminal.
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { ui } from "./ui.js";
|
|
5
|
+
import { Tree } from "../core/tree.js";
|
|
6
|
+
import { findPersons, label } from "../core/people.js";
|
|
7
|
+
import { agentsHere } from "../core/apps.js";
|
|
8
|
+
import { AGENTS } from "../core/which.js";
|
|
9
|
+
/** The test (or a script) gave all its answers: nothing more is asked. */
|
|
10
|
+
export function outOfAnswers(ctx) {
|
|
11
|
+
return ctx.io.answers !== undefined && ctx.io.answers.length === 0;
|
|
12
|
+
}
|
|
13
|
+
/** "Press Enter to go back to the menu." — unless there is nobody to press it. */
|
|
14
|
+
export async function pause(ctx, lang) {
|
|
15
|
+
if (!outOfAnswers(ctx))
|
|
16
|
+
await ctx.ask(ui(lang, "ui.enter"));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A submenu: its items (built afresh each time, so what they say is current), numbered in the order shown, 0 back.
|
|
20
|
+
* An item's act returning true leaves the submenu. An act waits for Enter itself, only after a long answer —
|
|
21
|
+
* never after 0 or a question left: the person went back, nothing to read.
|
|
22
|
+
*/
|
|
23
|
+
export async function subMenu(ctx, lang, build) {
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (outOfAnswers(ctx))
|
|
26
|
+
return;
|
|
27
|
+
const { title, items } = build();
|
|
28
|
+
const all = [...items, { key: "0", label: ui(lang, "ui.browse.back"), act: async () => true }];
|
|
29
|
+
ctx.io.stdout("\n");
|
|
30
|
+
let n = 0;
|
|
31
|
+
// Enter goes back: an item here changes or starts something, a person picks it by its number
|
|
32
|
+
const i = await ctx.choose(title ?? "", all.map((it) => ({ key: it.key === "0" ? "0" : String(++n), label: it.label })), all.length - 1);
|
|
33
|
+
if (i === undefined)
|
|
34
|
+
return;
|
|
35
|
+
if ((await guarded(ctx, lang, all[i].act)) === true)
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Ask for a person by name or ID; several match: pick one. Nobody (Enter, or no answers left): undefined. */
|
|
40
|
+
export async function pickPerson(ctx, lang, root, question, suggested) {
|
|
41
|
+
const tree = Tree.open(root, ctx.env);
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (outOfAnswers(ctx))
|
|
44
|
+
return undefined;
|
|
45
|
+
const who = (await ctx.ask(question, suggested)).trim();
|
|
46
|
+
if (!who || who === "0")
|
|
47
|
+
return undefined;
|
|
48
|
+
const byId = /^P\d+$/i.test(who) ? tree.get(who.toUpperCase()) : undefined;
|
|
49
|
+
const hits = byId && !byId.retracted ? [byId] : findPersons(tree, who);
|
|
50
|
+
if (!hits.length)
|
|
51
|
+
ctx.io.stdout(ui(lang, "ui.review.none", { who }) + "\n");
|
|
52
|
+
else if (hits.length === 1)
|
|
53
|
+
return hits[0];
|
|
54
|
+
else {
|
|
55
|
+
const i = await ctx.choose(ui(lang, "ui.review.pick"), hits.slice(0, 9).map((p) => ({ label: label(p) })), 0, { back: ui(lang, "ui.browse.back") });
|
|
56
|
+
return i === undefined ? undefined : hits[i];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The paths in what a person typed or dropped into the terminal: a file dragged in comes quoted ('…', "…") or
|
|
62
|
+
* with its spaces escaped (macOS, Linux: My\ Folder), several of them separated by spaces. On Windows a backslash
|
|
63
|
+
* is the path's own, never an escape.
|
|
64
|
+
*/
|
|
65
|
+
export function droppedPaths(answer, platform = process.platform) {
|
|
66
|
+
const out = [];
|
|
67
|
+
let cur = "";
|
|
68
|
+
let quote;
|
|
69
|
+
let any = false;
|
|
70
|
+
const s = answer.trim();
|
|
71
|
+
for (let i = 0; i < s.length; i++) {
|
|
72
|
+
const c = s[i];
|
|
73
|
+
if (quote) {
|
|
74
|
+
if (c === quote)
|
|
75
|
+
quote = undefined;
|
|
76
|
+
else
|
|
77
|
+
cur += c;
|
|
78
|
+
}
|
|
79
|
+
else if (c === "'" || c === '"') {
|
|
80
|
+
quote = c;
|
|
81
|
+
any = true;
|
|
82
|
+
}
|
|
83
|
+
else if (c === "\\" && platform !== "win32" && i + 1 < s.length) {
|
|
84
|
+
cur += s[++i];
|
|
85
|
+
any = true;
|
|
86
|
+
}
|
|
87
|
+
else if (/\s/u.test(c)) {
|
|
88
|
+
if (cur || any)
|
|
89
|
+
out.push(cur);
|
|
90
|
+
cur = "";
|
|
91
|
+
any = false;
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
cur += c;
|
|
95
|
+
}
|
|
96
|
+
if (cur || any)
|
|
97
|
+
out.push(cur);
|
|
98
|
+
// some terminals drop a file as its address: file:///Users/…/My%20Folder
|
|
99
|
+
return out.filter(Boolean).map((p) => {
|
|
100
|
+
// Git Bash on Windows: /c/Users/… is C:\Users\…
|
|
101
|
+
const drive = platform === "win32" ? /^\/([a-zA-Z])(\/.*)?$/u.exec(p) : null;
|
|
102
|
+
if (drive)
|
|
103
|
+
return `${drive[1].toUpperCase()}:${(drive[2] ?? "/").replace(/\//gu, "\\")}`;
|
|
104
|
+
if (!/^file:\/\//iu.test(p))
|
|
105
|
+
return p;
|
|
106
|
+
try {
|
|
107
|
+
return fileURLToPath(p);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return p;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** The translator of one language, as the menu's items use it. */
|
|
115
|
+
export function translator(lang) {
|
|
116
|
+
return (key, values = {}) => ui(lang, key, values);
|
|
117
|
+
}
|
|
118
|
+
/** Is the agent of the research on this computer — its CLI or its desktop app? Without it, nothing is offered to start with it. */
|
|
119
|
+
export function agentReady(ctx, tree) {
|
|
120
|
+
const agent = ctx.settings.agent(tree).value;
|
|
121
|
+
// an agent strom does not look for (a script of the user's) is theirs to have
|
|
122
|
+
return !AGENTS.some((a) => a.id === agent) || agentsHere(ctx.env).some((a) => a.id === agent);
|
|
123
|
+
}
|
|
124
|
+
/** Claude Code is the agent of the research and on this computer: only then does the menu speak of what is Claude's (Remote Control, its usage). */
|
|
125
|
+
export function claudeHere(ctx, tree) {
|
|
126
|
+
return ctx.settings.agent(tree).value === "claude" && agentsHere(ctx.env).some((a) => a.id === "claude" && a.cli);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* An item's act, and what goes wrong in it said in a sentence — the menu goes on (a folder that could not be read, a
|
|
130
|
+
* command that refused): never a crash of the menu with an agent's error.
|
|
131
|
+
*/
|
|
132
|
+
export async function guarded(ctx, lang, act) {
|
|
133
|
+
try {
|
|
134
|
+
return await act();
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
ctx.io.stdout(ui(lang, "ui.menu.failed", { reason: err.message }) + "\n");
|
|
138
|
+
await pause(ctx, lang);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The menu's "Add to the research": material from the family (strom intake), a
|
|
2
|
+
// new direction (strom research new), one person looked at again (strom review).
|
|
3
|
+
// Each an ordinary command; what the person reads is in their language, and the
|
|
4
|
+
// agent takes it up in the next conversation or run.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { agentReady, droppedPaths, pause, pickPerson, subMenu, translator } from "./menu-parts.js";
|
|
8
|
+
import { Tree } from "../core/tree.js";
|
|
9
|
+
import { displayName } from "../core/people.js";
|
|
10
|
+
import { expandHome } from "../core/paths.js";
|
|
11
|
+
import { collectFiles } from "../core/media.js";
|
|
12
|
+
import { BOOK_OF_SCANS, IMAGE_EXT } from "../commands/intake.js";
|
|
13
|
+
export async function addToResearch(ctx, run, lang, root) {
|
|
14
|
+
const t = translator(lang);
|
|
15
|
+
await subMenu(ctx, lang, () => {
|
|
16
|
+
const items = [
|
|
17
|
+
{ key: "1", label: t("ui.more.intake"), act: async () => void (await addMaterial(ctx, run, lang, root)) },
|
|
18
|
+
{ key: "2", label: t("ui.more.research"), act: async () => void (await newResearch(ctx, run, lang, root)) },
|
|
19
|
+
];
|
|
20
|
+
if (Tree.open(root, ctx.env).count("person") > 0)
|
|
21
|
+
items.push({ key: "3", label: t("ui.menu.review"), act: async () => void (await reviewPerson(ctx, run, lang, root)) });
|
|
22
|
+
return { title: t("ui.more.title"), items };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Files, folders or what the person knows, as inputs of the research; the agent reads them in its next work. */
|
|
26
|
+
async function addMaterial(ctx, run, lang, root) {
|
|
27
|
+
const t = translator(lang);
|
|
28
|
+
const out = (line) => ctx.io.stdout(line + "\n");
|
|
29
|
+
const how = await ctx.choose(t("ui.intake.what"), [{ label: t("ui.intake.files") }, { label: t("ui.intake.text") }], 0, { back: t("ui.browse.back") });
|
|
30
|
+
if (how === undefined)
|
|
31
|
+
return;
|
|
32
|
+
let argv;
|
|
33
|
+
if (how === 1) {
|
|
34
|
+
const text = (await ctx.ask(t("ui.intake.write"))).trim();
|
|
35
|
+
if (!text || text === "0")
|
|
36
|
+
return;
|
|
37
|
+
argv = ["intake", `--text=${text}`]; // the person's words, whatever they start with
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
let paths = await askPaths(ctx, lang, root);
|
|
41
|
+
if (!paths)
|
|
42
|
+
return;
|
|
43
|
+
// Nothing to take in (an empty folder, hidden files only): said so.
|
|
44
|
+
if (!collectFiles(paths).length)
|
|
45
|
+
return void out(t("ui.intake.empty"));
|
|
46
|
+
// A folder of many images is the scans of a book: those go to a record set, which the agent makes in a conversation —
|
|
47
|
+
// the rest is taken in.
|
|
48
|
+
const scans = paths
|
|
49
|
+
.filter((p) => fs.statSync(p).isDirectory())
|
|
50
|
+
.map((p) => ({ p, n: collectFiles([p]).filter((f) => IMAGE_EXT.has(path.extname(f).toLowerCase())).length }))
|
|
51
|
+
.filter((x) => x.n > BOOK_OF_SCANS);
|
|
52
|
+
let documents = false;
|
|
53
|
+
for (const s of scans) {
|
|
54
|
+
if (await ctx.confirm(t("ui.intake.scans", { folder: path.basename(s.p), n: s.n }), false))
|
|
55
|
+
documents = true;
|
|
56
|
+
else {
|
|
57
|
+
out(t("ui.intake.book", { folder: path.basename(s.p) }));
|
|
58
|
+
paths = paths.filter((p) => p !== s.p);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!paths.length)
|
|
62
|
+
return;
|
|
63
|
+
argv = ["intake", ...paths, ...(documents ? ["--documents"] : [])];
|
|
64
|
+
}
|
|
65
|
+
const before = Tree.open(root, ctx.env).count("input");
|
|
66
|
+
if ((await run(argv, true)) !== 0)
|
|
67
|
+
return;
|
|
68
|
+
const added = Tree.open(root, ctx.env).count("input") - before;
|
|
69
|
+
if (!added)
|
|
70
|
+
return void out(t("ui.intake.known"));
|
|
71
|
+
out(t("ui.intake.done", { n: added }));
|
|
72
|
+
await offerChat(ctx, run, lang, root, t("ui.intake.chat"), t("ui.intake.say"));
|
|
73
|
+
}
|
|
74
|
+
/** On with the agent now — when there is one; else it is said that the agent takes it up once it is here. */
|
|
75
|
+
export async function offerChat(ctx, run, lang, root, question, say) {
|
|
76
|
+
if (!agentReady(ctx, Tree.open(root, ctx.env).config))
|
|
77
|
+
return void ctx.io.stdout(translator(lang)("ui.more.noagent") + "\n");
|
|
78
|
+
if (await ctx.confirm(question, true))
|
|
79
|
+
await run(["chat", "--say", say]);
|
|
80
|
+
}
|
|
81
|
+
/** Paths typed or dropped in, each one there and none of strom's own; asked again until they are, nothing (Enter, 0): undefined. */
|
|
82
|
+
async function askPaths(ctx, lang, root) {
|
|
83
|
+
const t = translator(lang);
|
|
84
|
+
const own = [root, ctx.settings.home()?.value].filter((d) => Boolean(d));
|
|
85
|
+
const inside = (p, dir) => {
|
|
86
|
+
const rel = path.relative(dir, p);
|
|
87
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
88
|
+
};
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (ctx.io.answers !== undefined && ctx.io.answers.length === 0)
|
|
91
|
+
return undefined;
|
|
92
|
+
const answer = (await ctx.ask(t("ui.intake.path"))).trim();
|
|
93
|
+
if (!answer || answer === "0")
|
|
94
|
+
return undefined;
|
|
95
|
+
const resolve = (p) => path.resolve(ctx.cwd, expandHome(p, ctx.env));
|
|
96
|
+
let paths = droppedPaths(answer).map(resolve);
|
|
97
|
+
// one path with spaces, typed without quotes
|
|
98
|
+
if (paths.some((p) => !fs.existsSync(p)) && fs.existsSync(resolve(answer)))
|
|
99
|
+
paths = [resolve(answer)];
|
|
100
|
+
const missing = paths.filter((p) => !fs.existsSync(p));
|
|
101
|
+
// strom's own folders (the research, its home) are not material of the family: they would be copied into themselves
|
|
102
|
+
const ours = paths.filter((p) => own.some((d) => inside(p, d) || inside(d, p)));
|
|
103
|
+
if (ours.length)
|
|
104
|
+
ctx.io.stdout(t("ui.intake.ours", { path: ctx.display(ours[0]) }) + "\n");
|
|
105
|
+
else if (!missing.length && paths.length)
|
|
106
|
+
return paths;
|
|
107
|
+
else
|
|
108
|
+
ctx.io.stdout(t("ui.intake.missing", { path: missing.map((p) => ctx.display(p)).join(", ") || answer }) + "\n");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** A new direction: the ancestors or the descendants of someone, or one question — and what the person knows of it. */
|
|
112
|
+
async function newResearch(ctx, run, lang, root) {
|
|
113
|
+
const t = translator(lang);
|
|
114
|
+
const kinds = ["ancestors", "descendants", "question"];
|
|
115
|
+
const k = await ctx.choose(t("ui.research.kind"), [{ label: t("ui.research.ancestors") }, { label: t("ui.research.descendants") }, { label: t("ui.research.question") }], 0, {
|
|
116
|
+
back: t("ui.browse.back"),
|
|
117
|
+
});
|
|
118
|
+
if (k === undefined)
|
|
119
|
+
return;
|
|
120
|
+
const direction = kinds[k];
|
|
121
|
+
let question;
|
|
122
|
+
if (direction === "question") {
|
|
123
|
+
question = (await ctx.ask(t("ui.research.ask"))).trim();
|
|
124
|
+
if (!question || question === "0")
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Whom: someone in the tree, or a new person (a name and sex; what else is known goes in as the person's words).
|
|
128
|
+
const people = Tree.open(root, ctx.env).count("person") > 0;
|
|
129
|
+
const inTree = people ? await ctx.choose(t("ui.research.whom"), [{ label: t("ui.research.known") }, { label: t("ui.research.newperson") }], 0, { back: t("ui.browse.back") }) : 1;
|
|
130
|
+
if (inTree === undefined)
|
|
131
|
+
return;
|
|
132
|
+
let who;
|
|
133
|
+
let name;
|
|
134
|
+
if (inTree === 0) {
|
|
135
|
+
const p = await pickPerson(ctx, lang, root, t("ui.review.who"));
|
|
136
|
+
if (!p)
|
|
137
|
+
return;
|
|
138
|
+
who = ["--person", p.id];
|
|
139
|
+
name = displayName(p);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
name = (await ctx.ask(t("ui.research.name"))).trim().replace(/\s+/gu, " ");
|
|
143
|
+
if (!name || name === "0")
|
|
144
|
+
return;
|
|
145
|
+
const sex = await ctx.choose(t("ui.research.sex"), [{ label: t("ui.research.male") }, { label: t("ui.research.female") }, { label: t("ui.research.unknown") }], 2, { back: t("ui.browse.back") });
|
|
146
|
+
if (sex === undefined)
|
|
147
|
+
return;
|
|
148
|
+
who = [`--new-person=${name}`, "--sex", ["M", "F", "U"][sex]];
|
|
149
|
+
}
|
|
150
|
+
// The same research again (this person, this direction, still going): said which one it is, nothing made.
|
|
151
|
+
const focus = who[0] === "--person" ? who[1] : undefined;
|
|
152
|
+
const same = focus && direction !== "question"
|
|
153
|
+
? Tree.open(root, ctx.env)
|
|
154
|
+
.list("research")
|
|
155
|
+
.find((r) => r.focus === focus && r.direction === direction && r.state === "active")
|
|
156
|
+
: undefined;
|
|
157
|
+
if (same)
|
|
158
|
+
return void ctx.io.stdout(t("ui.research.exists", { name: same.name }) + "\n");
|
|
159
|
+
const suggested = question ?? t(direction === "ancestors" ? "ui.research.title.ancestors" : "ui.research.title.descendants", { name });
|
|
160
|
+
const title = (await ctx.ask(t("ui.research.title"), suggested)).trim();
|
|
161
|
+
if (title === "0")
|
|
162
|
+
return;
|
|
163
|
+
const knows = (await ctx.ask(t("ui.research.knows"))).trim();
|
|
164
|
+
const before = new Set(Tree.open(root, ctx.env)
|
|
165
|
+
.list("research")
|
|
166
|
+
.map((r) => r.id));
|
|
167
|
+
if ((await run(["research", "new", ...who, "--direction", direction, ...(question ? [`--question=${question}`] : []), "--", title || suggested], true)) !== 0)
|
|
168
|
+
return;
|
|
169
|
+
const made = Tree.open(root, ctx.env)
|
|
170
|
+
.list("research")
|
|
171
|
+
.find((r) => !before.has(r.id));
|
|
172
|
+
if (!made)
|
|
173
|
+
return;
|
|
174
|
+
if (knows && knows !== "0")
|
|
175
|
+
await run(["intake", `--text=${knows}`, "--research", made.id], true);
|
|
176
|
+
ctx.io.stdout(t("ui.research.done", { name: made.name }) + "\n");
|
|
177
|
+
await offerChat(ctx, run, lang, root, t("ui.research.chat"), t("ui.research.say", { name: made.name }));
|
|
178
|
+
}
|
|
179
|
+
/** One person, looked at again: what the tree says of them elsewhere, what to read whole, what to check. */
|
|
180
|
+
async function reviewPerson(ctx, run, lang, root) {
|
|
181
|
+
const t = translator(lang);
|
|
182
|
+
const person = await pickPerson(ctx, lang, root, t("ui.review.who"));
|
|
183
|
+
if (!person)
|
|
184
|
+
return;
|
|
185
|
+
const family = await ctx.confirm(t("ui.review.family"), false);
|
|
186
|
+
if ((await run(["review", person.id, ...(family ? ["--scope", "family"] : [])])) !== 0)
|
|
187
|
+
return pause(ctx, lang);
|
|
188
|
+
const tree = Tree.open(root, ctx.env);
|
|
189
|
+
const research = tree.list("research").find((r) => r.direction === "person" && r.focus === person.id && r.review);
|
|
190
|
+
const open = research ? tree.list("task").filter((x) => x.research === research.id && x.state === "open").length : 0;
|
|
191
|
+
if (research && open && (await ctx.confirm(t("ui.review.run", { n: open }), false)))
|
|
192
|
+
await run(["run", "--research", research.id, "--max", String(open)]);
|
|
193
|
+
await pause(ctx, lang);
|
|
194
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// The menu's settings: what belongs to this computer and to the person alone —
|
|
2
|
+
// the setup wizard, the agent working alone (its time, the condition it works
|
|
3
|
+
// on under), hooks, Remote Control, logins to archives and the consent to
|
|
4
|
+
// downloaders, the check of the installation. Only the user changes these; here
|
|
5
|
+
// the person at the terminal does, through the ordinary commands.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { claudeHere, pause, subMenu, translator } from "./menu-parts.js";
|
|
8
|
+
import { Tree } from "../core/tree.js";
|
|
9
|
+
import { DEFAULT_RUN_MINUTES } from "../core/config.js";
|
|
10
|
+
import { askGate, ensureGatesDir, listGates, loadGate } from "../core/gate.js";
|
|
11
|
+
import { ensureHooksDir, hooksDir, listHooks } from "../core/hooks.js";
|
|
12
|
+
import { listConnectors, routeOf, routesOf } from "../core/connector.js";
|
|
13
|
+
import { claudeInChrome, CLAUDE_IN_CHROME_URL } from "../core/browser.js";
|
|
14
|
+
import { PROFILES } from "../agents/profiles.js";
|
|
15
|
+
import { loadLogins } from "../core/logins.js";
|
|
16
|
+
import { claudeRemoteAtStartup } from "../agents/global.js";
|
|
17
|
+
export async function settingsMenu(ctx, run, lang, root, newer) {
|
|
18
|
+
const t = translator(lang);
|
|
19
|
+
await subMenu(ctx, lang, () => {
|
|
20
|
+
ctx.settings.reload();
|
|
21
|
+
const shared = ctx.settings.shared()?.value;
|
|
22
|
+
const tree = root ? Tree.open(root, ctx.env).config : undefined;
|
|
23
|
+
const minutes = ctx.settings.number("run.minutes", tree, DEFAULT_RUN_MINUTES);
|
|
24
|
+
const gate = ctx.settings.runGate();
|
|
25
|
+
const on = ctx.settings.config.hooks ?? [];
|
|
26
|
+
const anyHook = shared ? listHooks(shared).length > 0 : false;
|
|
27
|
+
// The items always here first, in the same order; what shows only sometimes (Remote Control, a newer strom) after
|
|
28
|
+
// them — the numbers a person knows never move.
|
|
29
|
+
const items = [
|
|
30
|
+
// After the wizard the menu starts afresh: the research may be in another folder now.
|
|
31
|
+
{ key: "1", label: t("ui.settings.wizard"), act: async () => (await run(["setup"]), true) },
|
|
32
|
+
{ key: "2", label: t("ui.settings.alone", { minutes, gate: gate ? gateName(shared, gate) : t("ui.settings.gate.none") }), act: async () => alone(ctx, run, lang, root) },
|
|
33
|
+
{
|
|
34
|
+
key: "3",
|
|
35
|
+
label: t("ui.settings.hooks", { state: on.length ? on.join(", ") : t(anyHook ? "ui.settings.off" : "ui.hooks.nothing") }),
|
|
36
|
+
act: async () => (shared ? hooks(ctx, run, lang, shared) : undefined),
|
|
37
|
+
},
|
|
38
|
+
{ key: "4", label: t("ui.settings.archives"), act: async () => archives(ctx, run, lang, shared, root) },
|
|
39
|
+
{
|
|
40
|
+
key: "5",
|
|
41
|
+
label: t("ui.menu.doctor"),
|
|
42
|
+
act: async () => {
|
|
43
|
+
const code = await run(["doctor"]);
|
|
44
|
+
if (code !== 0 && (await ctx.confirm(t("ui.menu.fix"), true)))
|
|
45
|
+
await run(["doctor", "--fix"]);
|
|
46
|
+
await pause(ctx, lang);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
if (claudeHere(ctx, tree)) {
|
|
51
|
+
// Claude Code may do it for its conversations itself (its own setting): what holds is said as it is.
|
|
52
|
+
const own = claudeRemoteAtStartup(ctx.env);
|
|
53
|
+
const remote = ctx.settings.agentRemote();
|
|
54
|
+
items.push({
|
|
55
|
+
key: "6",
|
|
56
|
+
label: t("ui.settings.remote", { state: t(remote ? "ui.settings.remote.both" : own ? "ui.settings.remote.chats" : "ui.settings.off") }),
|
|
57
|
+
act: async () => {
|
|
58
|
+
ctx.io.stdout(t(own ? "ui.settings.remote.own" : "ui.settings.remote.about") + "\n");
|
|
59
|
+
if (await ctx.confirm(t(remote ? "ui.settings.remote.off" : own ? "ui.settings.remote.onalone" : "ui.settings.remote.on"), !remote))
|
|
60
|
+
await run(["config", "set", "agent.remote", remote ? "off" : "on"], true);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (newer)
|
|
65
|
+
items.push({ key: "7", label: t("ui.menu.update", { version: newer }), act: async () => void (await run(["update"])) });
|
|
66
|
+
return { title: t("ui.settings.title"), items };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** A gate as the user reads it: its title and what it is given ("Claude usage 10"). */
|
|
70
|
+
function gateName(shared, spec) {
|
|
71
|
+
if (!shared)
|
|
72
|
+
return spec;
|
|
73
|
+
try {
|
|
74
|
+
const g = loadGate(shared, spec);
|
|
75
|
+
return [g.manifest.title ?? g.name, ...g.args].join(" ");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return spec;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The agent working alone: how long one session may take, and the condition it works on under (run.gate). */
|
|
82
|
+
async function alone(ctx, run, lang, root) {
|
|
83
|
+
const t = translator(lang);
|
|
84
|
+
const out = (line) => ctx.io.stdout(line + "\n");
|
|
85
|
+
await subMenu(ctx, lang, () => {
|
|
86
|
+
ctx.settings.reload();
|
|
87
|
+
const shared = ctx.settings.shared()?.value;
|
|
88
|
+
const tree = root ? Tree.open(root, ctx.env).config : undefined;
|
|
89
|
+
const minutes = ctx.settings.number("run.minutes", tree, DEFAULT_RUN_MINUTES);
|
|
90
|
+
const set = ctx.settings.runGate();
|
|
91
|
+
const items = [
|
|
92
|
+
{
|
|
93
|
+
key: "1",
|
|
94
|
+
label: t("ui.alone.minutes", { minutes }),
|
|
95
|
+
act: async () => {
|
|
96
|
+
for (;;) {
|
|
97
|
+
const a = (await ctx.ask(t("ui.alone.minutes.ask"), String(minutes))).trim();
|
|
98
|
+
if (a === "0" || a === String(minutes))
|
|
99
|
+
return;
|
|
100
|
+
const n = Number(a);
|
|
101
|
+
// a tree with a time of its own: the change is for it, else it would not show
|
|
102
|
+
const own = root ? ctx.settings.resolve("run.minutes", tree)?.source === "tree" : false;
|
|
103
|
+
if (Number.isInteger(n) && n >= 5 && n <= 600)
|
|
104
|
+
return void (await run(["config", "set", "run.minutes", String(n), ...(own ? ["--for-tree"] : [])], true));
|
|
105
|
+
out(t("ui.alone.minutes.bad"));
|
|
106
|
+
if (ctx.io.answers !== undefined && ctx.io.answers.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
if (shared)
|
|
113
|
+
items.push({
|
|
114
|
+
key: "2",
|
|
115
|
+
label: t("ui.alone.gate", { gate: set ? gateName(shared, set) : t("ui.settings.gate.none") }),
|
|
116
|
+
act: async () => {
|
|
117
|
+
ensureGatesDir(shared);
|
|
118
|
+
// strom's claude-usage asks Claude Code: offered only where it is
|
|
119
|
+
const claude = claudeHere(ctx, tree);
|
|
120
|
+
const gates = listGates(shared).filter((g) => g.name !== "claude-usage" || claude || loadGateName(set ?? "") === g.name);
|
|
121
|
+
out(t("ui.alone.gate.about"));
|
|
122
|
+
const current = set ? gates.findIndex((g) => g.name === loadGateName(set)) : gates.length;
|
|
123
|
+
const i = await ctx.choose(t("ui.alone.gate.pick"), [...gates.map((g) => ({ label: g.manifest.title ?? g.name })), { label: t("ui.settings.gate.none") }], current >= 0 ? current : gates.length + 1, {
|
|
124
|
+
back: t("ui.browse.back"),
|
|
125
|
+
});
|
|
126
|
+
if (i === undefined)
|
|
127
|
+
return;
|
|
128
|
+
if (i === gates.length) {
|
|
129
|
+
if (set)
|
|
130
|
+
await run(["config", "unset", "run.gate"], true);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const g = gates[i];
|
|
134
|
+
const had = set && loadGateName(set) === g.name ? set.trim().split(/\s+/u).slice(1).join(" ") : undefined;
|
|
135
|
+
let given = "";
|
|
136
|
+
for (;;) {
|
|
137
|
+
given = (await ctx.ask(t(g.name === "claude-usage" ? "ui.alone.gate.claude" : "ui.alone.gate.args"), had ?? (g.name === "claude-usage" ? "10" : ""))).trim();
|
|
138
|
+
// claude-usage takes a number of points (0–100); anything else would stop every run
|
|
139
|
+
if (g.name !== "claude-usage" || (/^\d+(?:[.,]\d+)?$/u.test(given) && Number(given.replace(",", ".")) <= 100))
|
|
140
|
+
break;
|
|
141
|
+
out(t("ui.alone.gate.bad"));
|
|
142
|
+
if (ctx.io.answers !== undefined && ctx.io.answers.length === 0)
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
given = g.name === "claude-usage" ? given.replace(",", ".") : given;
|
|
146
|
+
if (given === "0" && g.name !== "claude-usage")
|
|
147
|
+
return;
|
|
148
|
+
const spec = [g.name, given].filter(Boolean).join(" ");
|
|
149
|
+
if (spec !== set)
|
|
150
|
+
await run(["config", "set", "run.gate", spec], true);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
if (set && shared)
|
|
154
|
+
items.push({
|
|
155
|
+
key: "3",
|
|
156
|
+
label: t("ui.alone.test"),
|
|
157
|
+
act: async () => {
|
|
158
|
+
out(t("ui.alone.asking"));
|
|
159
|
+
const tr = root ? Tree.open(root, ctx.env) : undefined;
|
|
160
|
+
const agent = ctx.settings.agent(tr?.config).value;
|
|
161
|
+
let said;
|
|
162
|
+
try {
|
|
163
|
+
said = askGate(loadGate(shared, set), ctx.env, { tree: tr?.root ?? "", lang: tr?.lang ?? lang, agent, model: ctx.settings.models(agent, tr?.config).lead, sessions: 0, costUsd: 0 });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
said = { verdict: "error", reason: err.message };
|
|
167
|
+
}
|
|
168
|
+
const at = said.verdict === "wait" && said.waitMs
|
|
169
|
+
? new Date(Date.now() + said.waitMs).toLocaleString(lang, { weekday: "short", day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" })
|
|
170
|
+
: "";
|
|
171
|
+
out(t(`ui.alone.said.${said.verdict}`, { at, reason: said.reason ? ` — ${said.reason}` : "" }));
|
|
172
|
+
await pause(ctx, lang);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
return { title: t("ui.alone.title"), items };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/** The gate's name in "claude-usage 10". */
|
|
179
|
+
function loadGateName(spec) {
|
|
180
|
+
return spec.trim().split(/\s+/u)[0] ?? "";
|
|
181
|
+
}
|
|
182
|
+
/** Hooks: which there are, each turned on or off by the person here; none yet: how to make one. */
|
|
183
|
+
async function hooks(ctx, run, lang, shared) {
|
|
184
|
+
const t = translator(lang);
|
|
185
|
+
ensureHooksDir(shared);
|
|
186
|
+
if (!listHooks(shared).length) {
|
|
187
|
+
ctx.io.stdout(t("ui.hooks.none", { readme: ctx.display(path.join(hooksDir(shared), "README.md")) }) + "\n");
|
|
188
|
+
return pause(ctx, lang);
|
|
189
|
+
}
|
|
190
|
+
await subMenu(ctx, lang, () => {
|
|
191
|
+
ctx.settings.reload();
|
|
192
|
+
const on = new Set(ctx.settings.config.hooks ?? []);
|
|
193
|
+
const items = listHooks(shared).map((h, k) => ({
|
|
194
|
+
key: String(k + 1),
|
|
195
|
+
label: t(on.has(h.name) ? "ui.hooks.on" : "ui.hooks.off", { name: h.manifest.title ?? h.name }),
|
|
196
|
+
act: async () => {
|
|
197
|
+
// on: what it will be told, and a yes (off needs none)
|
|
198
|
+
if (!on.has(h.name) && !(await ctx.confirm(t("ui.consent.hook.on", { name: h.manifest.title ?? h.name }), false)))
|
|
199
|
+
return;
|
|
200
|
+
await run(["hook", on.has(h.name) ? "off" : "on", h.name], true);
|
|
201
|
+
},
|
|
202
|
+
}));
|
|
203
|
+
return { title: t("ui.hooks.title"), items };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Archives: the person's logins to portals (typed here, kept on this computer), and whether downloaders ask first. */
|
|
207
|
+
async function archives(ctx, run, lang, shared, root) {
|
|
208
|
+
const t = translator(lang);
|
|
209
|
+
const out = (line) => ctx.io.stdout(line + "\n");
|
|
210
|
+
await subMenu(ctx, lang, () => {
|
|
211
|
+
ctx.settings.reload();
|
|
212
|
+
const saved = loadLogins(ctx.env);
|
|
213
|
+
const connectors = listConnectors(shared);
|
|
214
|
+
const consent = ctx.settings.connectorsConsent();
|
|
215
|
+
const agent = ctx.settings.agent(root ? Tree.open(root, ctx.env).config : undefined).value;
|
|
216
|
+
const via = (c) => (c.manifest.policy.automation === "manual" ? "manual" : routeOf(ctx.env, c).via);
|
|
217
|
+
// Each downloader on a line of its own: how its images come, and its login where it can use one.
|
|
218
|
+
const lines = connectors.map((c) => {
|
|
219
|
+
const login = !c.manifest.login ? "" : `; ${saved[c.name] ? t("ui.archives.login.saved", { date: saved[c.name].at.slice(0, 10) }) : t("ui.archives.login.none")}`;
|
|
220
|
+
return ` • ${c.manifest.title ?? c.name} — ${t(`ui.archives.via.${via(c)}`)}${login}`;
|
|
221
|
+
});
|
|
222
|
+
// Through the browser: only Claude Code can, with the Claude in Chrome extension — said as it is here.
|
|
223
|
+
const browser = connectors.filter((c) => via(c) === "browser");
|
|
224
|
+
const names = browser.map((c) => c.manifest.title ?? c.name).join(", ");
|
|
225
|
+
const ext = browser.length && agent === "claude" ? claudeInChrome(ctx.env).extension : [];
|
|
226
|
+
const browserLine = !browser.length
|
|
227
|
+
? undefined
|
|
228
|
+
: agent !== "claude"
|
|
229
|
+
? t("ui.browser.agent", { names, agent: PROFILES[agent]?.name ?? agent })
|
|
230
|
+
: ext.length
|
|
231
|
+
? t("ui.archives.browser.ok", { browsers: ext.join(", ") })
|
|
232
|
+
: t("ui.browser.noext", { names, url: CLAUDE_IN_CHROME_URL });
|
|
233
|
+
const withLogin = connectors.filter((c) => c.manifest.login);
|
|
234
|
+
const title = connectors.length
|
|
235
|
+
? [t("ui.archives.title"), ...lines, "", ...(browserLine ? [browserLine] : []), t(withLogin.length ? "ui.archives.login.how" : "ui.archives.login.nobody")].join("\n")
|
|
236
|
+
: t("ui.archives.empty");
|
|
237
|
+
// A downloader that can take either way: switched here (the agents' permissions follow).
|
|
238
|
+
const switchable = connectors.filter((c) => via(c) !== "manual" && routesOf(c).includes("browser") && routesOf(c).includes("direct"));
|
|
239
|
+
const items = [
|
|
240
|
+
{
|
|
241
|
+
key: "1",
|
|
242
|
+
label: t("ui.archives.consent", { state: t(consent ? "ui.settings.on" : "ui.settings.off") }),
|
|
243
|
+
act: async () => {
|
|
244
|
+
out(t("ui.archives.consent.about"));
|
|
245
|
+
if (await ctx.confirm(t(consent ? "ui.archives.consent.off" : "ui.archives.consent.on"), false))
|
|
246
|
+
await run(["config", "set", "connectors.consent", consent ? "off" : "on"], true);
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
...switchable.map((c) => {
|
|
250
|
+
const next = via(c) === "browser" ? "direct" : "browser";
|
|
251
|
+
const name = c.manifest.title ?? c.name;
|
|
252
|
+
return {
|
|
253
|
+
key: "s",
|
|
254
|
+
label: t(next === "browser" ? "ui.archives.to.browser" : "ui.archives.to.direct", { title: name }),
|
|
255
|
+
act: async () => {
|
|
256
|
+
out(t(next === "browser" ? "ui.archives.browser.about" : "ui.archives.direct.about"));
|
|
257
|
+
if (!(await ctx.confirm(t("ui.archives.switch"), true)))
|
|
258
|
+
return;
|
|
259
|
+
if ((await run(["connector", "use", c.name, "--via", next], true)) === 0)
|
|
260
|
+
out(t(`ui.archives.now.${next}`, { title: name }));
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}),
|
|
264
|
+
...withLogin.map((c) => ({
|
|
265
|
+
key: "l",
|
|
266
|
+
label: t(saved[c.name] ? "ui.archives.login.change" : "ui.archives.login.save", { title: c.manifest.title ?? c.name }),
|
|
267
|
+
act: async () => void (await run(["login", c.name])),
|
|
268
|
+
})),
|
|
269
|
+
];
|
|
270
|
+
// at most nine
|
|
271
|
+
return { title, items: items.slice(0, 9) };
|
|
272
|
+
});
|
|
273
|
+
}
|