strom-research 1.0.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/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Per-invocation context: environment, settings, IO, and lazy access to the
|
|
2
|
+
// tree. Asking the user happens only here, and only on a terminal.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline/promises";
|
|
6
|
+
import { Writable } from "node:stream";
|
|
7
|
+
import { Settings } from "../core/config.js";
|
|
8
|
+
import { displayPath, expandHome } from "../core/paths.js";
|
|
9
|
+
import { NeedsConsentError, NeedsInputError, StromError, UsageError } from "../core/errors.js";
|
|
10
|
+
import { Tree, findTreeUpwards, isTreeDir } from "../core/tree.js";
|
|
11
|
+
import { readJsonIfExists } from "../core/json.js";
|
|
12
|
+
import { foldText } from "../core/text.js";
|
|
13
|
+
import { currentSession } from "../core/session.js";
|
|
14
|
+
import { isAgent } from "../core/which.js";
|
|
15
|
+
import { systemDialog } from "../core/dialog.js";
|
|
16
|
+
import { ui } from "./ui.js";
|
|
17
|
+
export class Context {
|
|
18
|
+
env;
|
|
19
|
+
cwd;
|
|
20
|
+
io;
|
|
21
|
+
settings;
|
|
22
|
+
json;
|
|
23
|
+
yes;
|
|
24
|
+
treeFlag;
|
|
25
|
+
limit;
|
|
26
|
+
page;
|
|
27
|
+
dryRun = false;
|
|
28
|
+
opened;
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.env = opts.env;
|
|
31
|
+
this.cwd = opts.cwd;
|
|
32
|
+
this.io = opts.io;
|
|
33
|
+
this.settings = new Settings(opts.env, opts.flags);
|
|
34
|
+
this.json = opts.json;
|
|
35
|
+
this.yes = opts.yes;
|
|
36
|
+
this.treeFlag = opts.tree;
|
|
37
|
+
this.limit = opts.limit ?? 50;
|
|
38
|
+
this.page = opts.page ?? 1;
|
|
39
|
+
}
|
|
40
|
+
/** A context from parsed command-line values (global options and settings flags). */
|
|
41
|
+
static fromOptions(o) {
|
|
42
|
+
const v = o.values;
|
|
43
|
+
const str = (k) => (typeof v[k] === "string" ? v[k] : undefined);
|
|
44
|
+
const positive = (k) => {
|
|
45
|
+
const raw = str(k);
|
|
46
|
+
if (raw === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
const n = Number(raw);
|
|
49
|
+
if (!Number.isInteger(n) || n < 1)
|
|
50
|
+
throw new UsageError(`--${k} must be a positive number`);
|
|
51
|
+
return n;
|
|
52
|
+
};
|
|
53
|
+
return new Context({
|
|
54
|
+
env: o.env,
|
|
55
|
+
cwd: o.cwd,
|
|
56
|
+
io: o.io,
|
|
57
|
+
flags: {
|
|
58
|
+
home: str("home"),
|
|
59
|
+
shared: str("shared"),
|
|
60
|
+
trees: str("trees"),
|
|
61
|
+
lang: str("lang"),
|
|
62
|
+
agent: str("agent"),
|
|
63
|
+
model: str("model"),
|
|
64
|
+
budget: str("budget"),
|
|
65
|
+
minutes: str("minutes"),
|
|
66
|
+
},
|
|
67
|
+
json: o.json,
|
|
68
|
+
yes: Boolean(v.yes),
|
|
69
|
+
tree: str("tree"),
|
|
70
|
+
limit: positive("limit"),
|
|
71
|
+
page: positive("page"),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Can we ask the user a question right now? */
|
|
75
|
+
get interactive() {
|
|
76
|
+
return (this.io.tty || this.io.answers !== undefined) && !this.yes && !this.json && this.env.STROM_NONINTERACTIVE !== "1";
|
|
77
|
+
}
|
|
78
|
+
async ask(question, suggested) {
|
|
79
|
+
const prompt = suggested ? `${question} [${suggested}] ` : `${question} `;
|
|
80
|
+
let answer;
|
|
81
|
+
if (this.io.answers) {
|
|
82
|
+
this.io.stdout(prompt + "\n");
|
|
83
|
+
answer = this.io.answers.shift() ?? "";
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const rl = readline.createInterface({ input: this.io.stdin ?? process.stdin, output: process.stdout });
|
|
87
|
+
try {
|
|
88
|
+
answer = await rl.question(prompt);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
rl.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
answer = answer.trim();
|
|
95
|
+
return answer === "" ? (suggested ?? "") : answer;
|
|
96
|
+
}
|
|
97
|
+
/** A question whose answer is not shown as it is typed (a password): kept as typed, spaces too. */
|
|
98
|
+
async askSecret(question) {
|
|
99
|
+
if (this.io.answers) {
|
|
100
|
+
this.io.stdout(`${question} \n`);
|
|
101
|
+
return this.io.answers.shift() ?? "";
|
|
102
|
+
}
|
|
103
|
+
let muted = false;
|
|
104
|
+
const quiet = new Writable({
|
|
105
|
+
write(chunk, encoding, done) {
|
|
106
|
+
if (!muted)
|
|
107
|
+
process.stdout.write(chunk, encoding);
|
|
108
|
+
done();
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const rl = readline.createInterface({ input: this.io.stdin ?? process.stdin, output: quiet, terminal: true });
|
|
112
|
+
try {
|
|
113
|
+
const answer = rl.question(`${question} `);
|
|
114
|
+
muted = true;
|
|
115
|
+
return (await answer).replace(/[\r\n]+$/, "");
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rl.close();
|
|
119
|
+
process.stdout.write("\n");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The language of the person using strom: the tree's (research language), else theirs. */
|
|
123
|
+
uiLang() {
|
|
124
|
+
let tree;
|
|
125
|
+
try {
|
|
126
|
+
const root = this.locateTree();
|
|
127
|
+
if (root)
|
|
128
|
+
tree = readJsonIfExists(path.join(root, "strom.json"));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// no tree to say
|
|
132
|
+
}
|
|
133
|
+
return this.settings.lang(tree).value;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A consent is the user's. In their own terminal they gave it by running the
|
|
137
|
+
* command. Anywhere else — an agent's session, the app — the person at the
|
|
138
|
+
* screen is asked in a window of the system, which the agent can neither see
|
|
139
|
+
* nor click. Without a window (no desktop, a run nobody watches) it fails:
|
|
140
|
+
* the user runs the command themselves. `says` is the question in the
|
|
141
|
+
* user's language — all the window shows, so it holds any warning too.
|
|
142
|
+
* `window: false` for what needs the user's terminal (typing a password).
|
|
143
|
+
*/
|
|
144
|
+
requireHuman(question, set, key, says, opts = {}) {
|
|
145
|
+
const agent = isAgent(this.env);
|
|
146
|
+
if (this.interactive && !agent)
|
|
147
|
+
return "terminal";
|
|
148
|
+
if (this.env.STROM_NONINTERACTIVE !== "1" && opts.window !== false) {
|
|
149
|
+
const lang = this.uiLang();
|
|
150
|
+
const text = { title: ui(lang, "ui.dialog.title"), question: says ?? question, yes: ui(lang, "ui.dialog.yes"), no: ui(lang, "ui.dialog.no") };
|
|
151
|
+
const answer = this.io.dialog ? this.io.dialog(text) : systemDialog(text, this.env);
|
|
152
|
+
if (answer === true)
|
|
153
|
+
return "window";
|
|
154
|
+
if (answer === false)
|
|
155
|
+
throw new StromError(ui(lang, "ui.dialog.refused"), { hint: `the user said no to: ${question} — do not ask again unless they want to` });
|
|
156
|
+
}
|
|
157
|
+
throw new NeedsConsentError([{ key, kind: "consent", question: `${question}${agent ? " (an agent cannot answer this)" : ""}`, set }]);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One of numbered options (1…n, or keys of their own like "0"); Enter takes
|
|
161
|
+
* the suggested one. Asks again on anything else. Undefined when a test ran
|
|
162
|
+
* out of answers.
|
|
163
|
+
*/
|
|
164
|
+
async choose(question, options, suggested) {
|
|
165
|
+
const keys = options.map((o, i) => o.key ?? String(i + 1));
|
|
166
|
+
for (;;) {
|
|
167
|
+
this.io.stdout(`\n${question ? `${question}\n` : ""}${options.map((o, i) => ` ${keys[i].padStart(2)} ${o.label}`).join("\n")}\n`);
|
|
168
|
+
if (this.io.answers && this.io.answers.length === 0)
|
|
169
|
+
return undefined;
|
|
170
|
+
const a = await this.ask(`${ui(this.uiLang(), "ui.choose")}`, keys[suggested]);
|
|
171
|
+
const i = keys.indexOf(a.trim());
|
|
172
|
+
if (i >= 0)
|
|
173
|
+
return i;
|
|
174
|
+
this.io.stdout(ui(this.uiLang(), "ui.bad.choice") + "\n");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async confirm(question, suggested = true) {
|
|
178
|
+
const lang = this.uiLang();
|
|
179
|
+
const [yes, no] = [ui(lang, "ui.yes"), ui(lang, "ui.no")];
|
|
180
|
+
const a = (await this.ask(`${question} (${yes}/${no})`, suggested ? yes : no)).toLowerCase();
|
|
181
|
+
return [yes, "y", "yes", "a", "ano", "j", "ja", "t", "tak", "o", "oui", "s", "si"].includes(a);
|
|
182
|
+
}
|
|
183
|
+
display(p) {
|
|
184
|
+
return displayPath(p, this.env);
|
|
185
|
+
}
|
|
186
|
+
resolvePath(p) {
|
|
187
|
+
return path.resolve(this.cwd, expandHome(p, this.env));
|
|
188
|
+
}
|
|
189
|
+
/** The Strom home; asks (TTY) or fails with needs-input. */
|
|
190
|
+
async requireHome() {
|
|
191
|
+
const home = this.settings.home();
|
|
192
|
+
if (home)
|
|
193
|
+
return home.value;
|
|
194
|
+
const suggested = this.settings.suggestedHome();
|
|
195
|
+
if (this.yes)
|
|
196
|
+
return this.saveHome(suggested);
|
|
197
|
+
if (!this.interactive)
|
|
198
|
+
throw new NeedsInputError([
|
|
199
|
+
{
|
|
200
|
+
key: "home",
|
|
201
|
+
kind: "config",
|
|
202
|
+
question: "Where should Strom keep the research (trees and shared data)?",
|
|
203
|
+
default: this.display(suggested),
|
|
204
|
+
scopes: ["user"],
|
|
205
|
+
set: `strom setup --home "${this.display(suggested)}" --yes`,
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
const answer = await this.ask("Where should Strom keep your research?", this.display(suggested));
|
|
209
|
+
return this.saveHome(this.resolvePath(answer));
|
|
210
|
+
}
|
|
211
|
+
saveHome(dir) {
|
|
212
|
+
this.settings.config.home = dir;
|
|
213
|
+
this.settings.save();
|
|
214
|
+
return dir;
|
|
215
|
+
}
|
|
216
|
+
treesDir() {
|
|
217
|
+
return this.settings.trees()?.value;
|
|
218
|
+
}
|
|
219
|
+
/** All trees Strom knows about: folders in the trees dir plus remembered ones. */
|
|
220
|
+
knownTrees() {
|
|
221
|
+
const roots = new Set();
|
|
222
|
+
const dir = this.treesDir();
|
|
223
|
+
if (dir && fs.existsSync(dir))
|
|
224
|
+
for (const name of fs.readdirSync(dir)) {
|
|
225
|
+
const root = path.join(dir, name);
|
|
226
|
+
if (isTreeDir(root))
|
|
227
|
+
roots.add(root);
|
|
228
|
+
}
|
|
229
|
+
for (const extra of this.settings.config.extraTrees ?? [])
|
|
230
|
+
if (isTreeDir(extra))
|
|
231
|
+
roots.add(extra);
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const root of [...roots].sort()) {
|
|
234
|
+
const cfg = readJsonIfExists(path.join(root, "strom.json"));
|
|
235
|
+
if (cfg)
|
|
236
|
+
out.push({ name: cfg.name, root, lang: cfg.lang });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
/** Locate the tree: --tree > STROM_TREE > current folder upwards > `strom trees use` > the only known tree. */
|
|
241
|
+
locateTree() {
|
|
242
|
+
const ref = this.treeFlag ?? this.env.STROM_TREE;
|
|
243
|
+
if (ref) {
|
|
244
|
+
const asPath = this.resolvePath(ref);
|
|
245
|
+
if (isTreeDir(asPath))
|
|
246
|
+
return asPath;
|
|
247
|
+
const key = foldText(ref);
|
|
248
|
+
const hits = this.knownTrees().filter((t) => foldText(t.name) === key || foldText(path.basename(t.root)) === key);
|
|
249
|
+
if (hits.length === 1)
|
|
250
|
+
return hits[0].root;
|
|
251
|
+
if (hits.length > 1)
|
|
252
|
+
throw new UsageError(`tree name "${ref}" is ambiguous`, { hint: "pass the folder path: --tree <dir>" });
|
|
253
|
+
throw new UsageError(`no tree "${ref}"`, { hint: "strom trees" });
|
|
254
|
+
}
|
|
255
|
+
const up = findTreeUpwards(this.cwd);
|
|
256
|
+
if (up)
|
|
257
|
+
return up;
|
|
258
|
+
const current = this.settings.config.currentTree;
|
|
259
|
+
if (current && isTreeDir(current))
|
|
260
|
+
return current;
|
|
261
|
+
const known = this.knownTrees();
|
|
262
|
+
if (known.length === 1)
|
|
263
|
+
return known[0].root;
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
tree() {
|
|
267
|
+
if (this.opened)
|
|
268
|
+
return this.opened;
|
|
269
|
+
const root = this.locateTree();
|
|
270
|
+
if (!root) {
|
|
271
|
+
const known = this.knownTrees();
|
|
272
|
+
if (known.length === 0)
|
|
273
|
+
throw new StromError("no tree yet", { hint: 'create one: strom init "<tree name>"' });
|
|
274
|
+
throw new UsageError("which tree? there are several", {
|
|
275
|
+
hint: `pass --tree <name>: ${known.map((t) => `"${t.name}"`).join(", ")}`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
this.opened = Tree.open(root, this.env);
|
|
279
|
+
this.opened.dryRun = this.dryRun;
|
|
280
|
+
// A language passed from outside (--lang, STROM_LANG — e.g. by the Strom
|
|
281
|
+
// app) applies to this invocation; the tree keeps its own.
|
|
282
|
+
const lang = this.settings.lang(this.opened.config);
|
|
283
|
+
if (lang.source === "flag" || lang.source === "env")
|
|
284
|
+
this.opened.langOverride = lang.value;
|
|
285
|
+
// Inside an open session every write is logged under the session's ID;
|
|
286
|
+
// an agent writing outside a session is logged as "agent", never as the user.
|
|
287
|
+
const session = currentSession(this.opened, this.env);
|
|
288
|
+
if (session)
|
|
289
|
+
this.opened.actor = session.id;
|
|
290
|
+
else if (isAgent(this.env))
|
|
291
|
+
this.opened.actor = "agent";
|
|
292
|
+
return this.opened;
|
|
293
|
+
}
|
|
294
|
+
/** The tree if a command already opened it. */
|
|
295
|
+
current() {
|
|
296
|
+
return this.opened;
|
|
297
|
+
}
|
|
298
|
+
/** Use a tree created during this command (e.g. by `init`). */
|
|
299
|
+
adopt(tree) {
|
|
300
|
+
tree.dryRun = this.dryRun;
|
|
301
|
+
this.opened = tree;
|
|
302
|
+
}
|
|
303
|
+
hasTree() {
|
|
304
|
+
try {
|
|
305
|
+
return this.locateTree() !== undefined;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Finding a command, parsing its options and checking its arguments — shared
|
|
2
|
+
// by the CLI entry and `strom batch`. Mistakes get a short error with a
|
|
3
|
+
// suggestion, never a wall of help: every extra line costs an agent tokens.
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { UsageError } from "../core/errors.js";
|
|
6
|
+
import { GLOBAL_OPTIONS, commands, match, optionsOf, subcommandsOf } from "./registry.js";
|
|
7
|
+
const VALUE_GLOBALS = new Set(GLOBAL_OPTIONS.filter((o) => o.type === "string").map((o) => `--${o.name}`));
|
|
8
|
+
/** Split argv into the command words and the remaining arguments. */
|
|
9
|
+
export function splitCommand(argv) {
|
|
10
|
+
const words = [];
|
|
11
|
+
const rest = [];
|
|
12
|
+
let collecting = true;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const tok = argv[i];
|
|
15
|
+
if (tok === "--") {
|
|
16
|
+
rest.push(...argv.slice(i));
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
if (tok.startsWith("-")) {
|
|
20
|
+
rest.push(tok);
|
|
21
|
+
if (VALUE_GLOBALS.has(tok) && i + 1 < argv.length)
|
|
22
|
+
rest.push(argv[++i]);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (collecting) {
|
|
26
|
+
const candidate = [...words, tok];
|
|
27
|
+
const extends_ = match(candidate)?.used === candidate.length || subcommandsOf(candidate).length > 0;
|
|
28
|
+
if (extends_) {
|
|
29
|
+
words.push(tok);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
collecting = false;
|
|
33
|
+
}
|
|
34
|
+
rest.push(tok);
|
|
35
|
+
}
|
|
36
|
+
return { words, rest };
|
|
37
|
+
}
|
|
38
|
+
/** Edit distance where swapping two neighbouring letters is one typo ("bron" → "born"). */
|
|
39
|
+
function distance(a, b) {
|
|
40
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
41
|
+
for (let j = 1; j <= b.length; j++)
|
|
42
|
+
d[0][j] = j;
|
|
43
|
+
for (let i = 1; i <= a.length; i++)
|
|
44
|
+
for (let j = 1; j <= b.length; j++) {
|
|
45
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
46
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
47
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
48
|
+
}
|
|
49
|
+
return d[a.length][b.length];
|
|
50
|
+
}
|
|
51
|
+
/** The closest candidates to `input` (a prefix, or a few typos), best first — only the best ones. */
|
|
52
|
+
export function suggest(input, candidates) {
|
|
53
|
+
const q = input.toLowerCase();
|
|
54
|
+
const scored = candidates
|
|
55
|
+
.map((c) => ({ c, d: c.startsWith(q) || q.startsWith(c) ? 0.5 : distance(q, c.toLowerCase()) }))
|
|
56
|
+
.filter((x) => x.d <= Math.max(2, Math.floor(q.length / 3)))
|
|
57
|
+
.sort((a, b) => a.d - b.d || a.c.length - b.c.length);
|
|
58
|
+
const best = scored[0]?.d;
|
|
59
|
+
return [...new Set(scored.filter((x) => x.d <= (best ?? 0) + 0.5).map((x) => x.c))].slice(0, 3);
|
|
60
|
+
}
|
|
61
|
+
/** The command named by argv, or a short error with suggestions. */
|
|
62
|
+
export function resolveCommand(argv) {
|
|
63
|
+
const { words, rest } = splitCommand(argv);
|
|
64
|
+
const found = match(words);
|
|
65
|
+
const leftover = found ? words.slice(found.used) : words;
|
|
66
|
+
// Words that are not options — the values of global options (--lang en) are no stray words.
|
|
67
|
+
const extra = rest.filter((r, i) => !r.startsWith("-") && !VALUE_GLOBALS.has(rest[i - 1] ?? ""));
|
|
68
|
+
if (found && leftover.length === 0) {
|
|
69
|
+
if (found.def.path.length === 0 && extra.length && rest[0] !== "--")
|
|
70
|
+
throw unknownCommand(extra.slice(0, 2));
|
|
71
|
+
return { def: found.def, rest };
|
|
72
|
+
}
|
|
73
|
+
// A group ("person") with a wrong or missing subcommand.
|
|
74
|
+
const group = found ? [...found.def.path, ...leftover] : words;
|
|
75
|
+
if (group.length && subcommandsOf(group).length) {
|
|
76
|
+
if (!extra.length)
|
|
77
|
+
throw new GroupOnly(group);
|
|
78
|
+
throw unknownCommand([...group, extra[0]]);
|
|
79
|
+
}
|
|
80
|
+
throw unknownCommand([...words, ...extra].slice(0, Math.max(words.length + 1, 2)));
|
|
81
|
+
}
|
|
82
|
+
/** Signal: a group name alone — the caller prints the group's short help. */
|
|
83
|
+
export class GroupOnly extends Error {
|
|
84
|
+
group;
|
|
85
|
+
constructor(group) {
|
|
86
|
+
super(`strom ${group.join(" ")} <command>`);
|
|
87
|
+
this.group = group;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function unknownCommand(words) {
|
|
91
|
+
const typed = words.join(" ");
|
|
92
|
+
const all = commands().filter((c) => c.path.length > 0).map((c) => c.path.join(" "));
|
|
93
|
+
// A known group narrows the choice ("person ad" → "person add", not "lesson add").
|
|
94
|
+
const inGroup = all.filter((c) => c.startsWith(`${words[0]} `));
|
|
95
|
+
const same = (inGroup.length ? inGroup : all).filter((c) => c.split(" ").length === words.length);
|
|
96
|
+
const hits = suggest(typed, same.length ? same : all);
|
|
97
|
+
const more = hits.length ? hits : suggest(words[0] ?? "", [...new Set(all.map((c) => c.split(" ")[0]))]);
|
|
98
|
+
return new UsageError(`unknown command "strom ${typed}"`, {
|
|
99
|
+
hint: more.length ? `did you mean: ${more.map((c) => `strom ${c}`).join(" · ")}` : "strom help (all commands)",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Parse options strictly; Node's messages are replaced by short ones with suggestions. */
|
|
103
|
+
export function parseOptions(def, rest) {
|
|
104
|
+
const defs = optionsOf(def);
|
|
105
|
+
const options = {};
|
|
106
|
+
for (const o of defs) {
|
|
107
|
+
options[o.name] = { type: o.type };
|
|
108
|
+
if (o.short)
|
|
109
|
+
options[o.name].short = o.short;
|
|
110
|
+
if (o.multiple)
|
|
111
|
+
options[o.name].multiple = true;
|
|
112
|
+
}
|
|
113
|
+
const cmd = `strom ${def.path.join(" ")}`.trim();
|
|
114
|
+
try {
|
|
115
|
+
const r = parseArgs({ args: rest, options, allowPositionals: true, strict: true });
|
|
116
|
+
return { values: r.values, positionals: r.positionals };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const e = err;
|
|
120
|
+
const opt = /'(-{1,2}[^' =]+)/.exec(e.message)?.[1] ?? "";
|
|
121
|
+
const own = [...(def.options ?? []), ...(def.writes ? optionsOf(def).filter((o) => o.name === "dry-run" || o.name === "reason") : [])].map((o) => `--${o.name}`);
|
|
122
|
+
if (e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
123
|
+
// "strom brief --task T0003": the task is an argument there, not an option
|
|
124
|
+
const arg = def.args?.find((a) => `--${a.name}` === opt);
|
|
125
|
+
if (arg)
|
|
126
|
+
throw new UsageError(`${opt.slice(2)} is an argument of ${cmd}, not an option`, { hint: `strom ${def.path.join(" ")} <${arg.name}>` });
|
|
127
|
+
const near = suggest(opt, optionsOf(def).map((o) => `--${o.name}`));
|
|
128
|
+
throw new UsageError(`unknown option ${opt} for ${cmd}`, {
|
|
129
|
+
hint: near.length ? `did you mean ${near.join(" or ")}?` : `${own.length ? `options: ${own.join(" ")} · ` : ""}strom help ${def.path.join(" ")}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (e.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
|
|
133
|
+
const o = defs.find((d) => `--${d.name}` === opt);
|
|
134
|
+
throw new UsageError(o?.type === "boolean" ? `${opt} takes no value` : `${opt} needs a value${o?.value ? ` ${o.value}` : ""}`, { hint: `strom help ${def.path.join(" ")}` });
|
|
135
|
+
}
|
|
136
|
+
throw new UsageError(e.message.split("\n")[0] ?? "invalid arguments", { hint: `strom help ${def.path.join(" ")}` });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Required and surplus positional arguments. */
|
|
140
|
+
export function checkArgs(def, args) {
|
|
141
|
+
const declared = def.args ?? [];
|
|
142
|
+
const required = declared.filter((a) => a.required);
|
|
143
|
+
if (args.length < required.length) {
|
|
144
|
+
const missing = required[args.length];
|
|
145
|
+
throw new UsageError(`missing <${missing.name}>: ${missing.description}`, { hint: `strom help ${def.path.join(" ")}` });
|
|
146
|
+
}
|
|
147
|
+
if (!declared.some((a) => a.variadic) && args.length > declared.length)
|
|
148
|
+
throw new UsageError(`unexpected argument "${args[declared.length]}"`, { hint: `strom help ${def.path.join(" ")} — quote values with spaces` });
|
|
149
|
+
}
|
|
150
|
+
/** Split off everything after a bare "--" (passed through to another program). */
|
|
151
|
+
export function splitPassthrough(rest) {
|
|
152
|
+
const i = rest.indexOf("--");
|
|
153
|
+
return i < 0 ? { rest, passthrough: [] } : { rest: rest.slice(0, i), passthrough: rest.slice(i + 1) };
|
|
154
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Getting what strom needs, with the user's yes — shared by the setup wizard
|
|
2
|
+
// and strom doctor --fix. A person in their own terminal is asked there; the
|
|
3
|
+
// user of an agent is asked in a window of the system (the agent can neither
|
|
4
|
+
// see nor click it); no window: the user runs strom doctor --fix themselves.
|
|
5
|
+
import { ui } from "./ui.js";
|
|
6
|
+
import { isAgent } from "../core/which.js";
|
|
7
|
+
import { gitVersion, resetCache } from "../core/git.js";
|
|
8
|
+
import { gitWay, installOwnGit, linuxGitCommand, MINGIT } from "../core/deps.js";
|
|
9
|
+
import { claudeInstaller, runInstaller } from "../core/install.js";
|
|
10
|
+
import { agentsHere } from "../core/apps.js";
|
|
11
|
+
import { researchUrl } from "../core/stromapp.js";
|
|
12
|
+
import { openForUser } from "../core/open.js";
|
|
13
|
+
import { NeedsConsentError, StromError } from "../core/errors.js";
|
|
14
|
+
/** Is the person answering here, in this terminal? */
|
|
15
|
+
function personHere(ctx) {
|
|
16
|
+
return ctx.interactive && !isAgent(ctx.env);
|
|
17
|
+
}
|
|
18
|
+
/** The user's yes to installing something: here, or in a window. False when they say no. */
|
|
19
|
+
async function yes(ctx, question, key) {
|
|
20
|
+
if (personHere(ctx))
|
|
21
|
+
return ctx.confirm(question, true);
|
|
22
|
+
try {
|
|
23
|
+
ctx.requireHuman(`install ${key}`, "strom doctor --fix", `install.${key}`, question);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (e instanceof StromError && !(e instanceof NeedsConsentError))
|
|
28
|
+
return false; // they said no
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Git: strom's own on Windows, Apple's tools on macOS, the package manager's on Linux. True when git works after it. */
|
|
33
|
+
export async function fixGit(ctx, out) {
|
|
34
|
+
const lang = ctx.uiLang();
|
|
35
|
+
const way = gitWay();
|
|
36
|
+
if (way === "command") {
|
|
37
|
+
out(ui(lang, "ui.setup.git.manual", { command: linuxGitCommand() }));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (!(await yes(ctx, ui(lang, way === "download" ? "ui.fix.git.download" : "ui.fix.git.window", { mb: MINGIT.mb }), "git")))
|
|
41
|
+
return false;
|
|
42
|
+
if (way === "download") {
|
|
43
|
+
out(ui(lang, "ui.fix.git.downloading"));
|
|
44
|
+
const ok = await installOwnGit(ctx.env, (why) => out(ui(lang, "ui.fix.failed", { detail: why })));
|
|
45
|
+
if (ok)
|
|
46
|
+
out(ui(lang, "ui.setup.git.ok", { version: gitVersion(ctx.env) ?? "" }));
|
|
47
|
+
return ok;
|
|
48
|
+
}
|
|
49
|
+
// macOS: Apple's window installs the command line tools; it takes a few minutes.
|
|
50
|
+
runInstaller({ command: "xcode-select", args: ["--install"] }, ctx.env);
|
|
51
|
+
if (!personHere(ctx)) {
|
|
52
|
+
out(ui(lang, "ui.fix.git.inwindow"));
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
await ctx.ask(ui(lang, "ui.setup.git.wait"));
|
|
56
|
+
resetCache();
|
|
57
|
+
const v = gitVersion(ctx.env);
|
|
58
|
+
if (v)
|
|
59
|
+
out(ui(lang, "ui.setup.git.ok", { version: v }));
|
|
60
|
+
return Boolean(v);
|
|
61
|
+
}
|
|
62
|
+
/** No agent on this computer: install Claude Code, show the page about agents, or later. Only a person here decides. */
|
|
63
|
+
export async function offerAgent(ctx, out) {
|
|
64
|
+
const lang = ctx.uiLang();
|
|
65
|
+
out(ui(lang, "ui.setup.agent.none"));
|
|
66
|
+
if (!personHere(ctx)) {
|
|
67
|
+
out(ui(lang, "ui.setup.agent.manual", { url: researchUrl(lang, "agents") }));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const how = await ctx.choose(ui(lang, "ui.choose"), [{ label: ui(lang, "ui.setup.agent.install") }, { label: ui(lang, "ui.setup.agent.guide") }, { label: ui(lang, "ui.setup.agent.later") }], 0);
|
|
71
|
+
if (how === 0) {
|
|
72
|
+
runInstaller(claudeInstaller(), ctx.env);
|
|
73
|
+
if (agentsHere(ctx.env).some((a) => a.id === "claude"))
|
|
74
|
+
out(ui(lang, "ui.setup.agent.login"));
|
|
75
|
+
}
|
|
76
|
+
else if (how === 1)
|
|
77
|
+
openForUser(researchUrl(lang, "agents"), ctx.env);
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Compact, token-efficient text output: aligned columns, one row per record,
|
|
2
|
+
// no box drawing, IDs first.
|
|
3
|
+
export function table(rows, opts = {}) {
|
|
4
|
+
const all = opts.header ? [opts.header, ...rows] : rows;
|
|
5
|
+
if (all.length === 0)
|
|
6
|
+
return "";
|
|
7
|
+
const gap = " ".repeat(opts.gap ?? 2);
|
|
8
|
+
const widths = [];
|
|
9
|
+
for (const row of all)
|
|
10
|
+
row.forEach((cell, i) => (widths[i] = Math.max(widths[i] ?? 0, [...cell].length)));
|
|
11
|
+
return all
|
|
12
|
+
.map((row) => row
|
|
13
|
+
.map((cell, i) => (i === row.length - 1 ? cell : cell + " ".repeat((widths[i] ?? 0) - [...cell].length)))
|
|
14
|
+
.join(gap)
|
|
15
|
+
.trimEnd())
|
|
16
|
+
.join("\n");
|
|
17
|
+
}
|
|
18
|
+
export function paginate(items, limit, page) {
|
|
19
|
+
const pages = Math.max(1, Math.ceil(items.length / limit));
|
|
20
|
+
const p = Math.min(Math.max(1, page), pages);
|
|
21
|
+
return { items: items.slice((p - 1) * limit, p * limit), total: items.length, page: p, pages, limit };
|
|
22
|
+
}
|
|
23
|
+
/** Footer telling the caller how to see more. */
|
|
24
|
+
export function moreLine(page, command) {
|
|
25
|
+
if (page.page >= page.pages)
|
|
26
|
+
return "";
|
|
27
|
+
const left = page.total - page.page * page.limit;
|
|
28
|
+
return `… ${left} more: ${command} --page ${page.page + 1}`;
|
|
29
|
+
}
|
|
30
|
+
export function truncate(text, max) {
|
|
31
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
32
|
+
return [...one].length <= max ? one : [...one].slice(0, max - 1).join("") + "…";
|
|
33
|
+
}
|
|
34
|
+
export function lines(...parts) {
|
|
35
|
+
return parts.filter((p) => typeof p === "string").join("\n");
|
|
36
|
+
}
|
|
37
|
+
/** Runs of numbers as ranges: [1, 2, 3, 7] → "1–3, 7". */
|
|
38
|
+
export function runs(nums) {
|
|
39
|
+
const s = [...new Set(nums)].sort((a, b) => a - b);
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let i = 0; i < s.length;) {
|
|
42
|
+
let j = i;
|
|
43
|
+
while (j + 1 < s.length && s[j + 1] === s[j] + 1)
|
|
44
|
+
j++;
|
|
45
|
+
out.push(i === j ? `${s[i]}` : `${s[i]}–${s[j]}`);
|
|
46
|
+
i = j + 1;
|
|
47
|
+
}
|
|
48
|
+
return out.join(", ");
|
|
49
|
+
}
|
|
50
|
+
/** An argument as it is typed in a shell: quoted when it holds spaces or special characters. */
|
|
51
|
+
export function shellArg(arg) {
|
|
52
|
+
return /^[\p{L}\p{M}\p{N}._\/:@+-]+$/u.test(arg) ? arg : `"${arg.replace(/["\\$`]/g, "\\$&")}"`;
|
|
53
|
+
}
|
package/dist/cli/help.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Help text generated from the registry. Short by design: examples first,
|
|
2
|
+
// then arguments and options.
|
|
3
|
+
import { GROUPS, GLOBAL_OPTIONS, WRITE_OPTIONS, commands, match, subcommandsOf, usageLine } from "./registry.js";
|
|
4
|
+
import { table } from "./format.js";
|
|
5
|
+
import { suggest } from "./execute.js";
|
|
6
|
+
function optionRows(opts) {
|
|
7
|
+
return opts.map((o) => [`--${o.name}${o.value ? ` ${o.value}` : ""}${o.multiple ? " (repeatable)" : ""}`, o.description]);
|
|
8
|
+
}
|
|
9
|
+
export function commandHelp(def) {
|
|
10
|
+
const out = [usageLine(def), "", def.summary];
|
|
11
|
+
if (def.description)
|
|
12
|
+
out.push("", def.description);
|
|
13
|
+
if (def.examples?.length)
|
|
14
|
+
out.push("", "Examples:", ...def.examples.map((e) => ` ${e}`));
|
|
15
|
+
if (def.args?.length)
|
|
16
|
+
out.push("", "Arguments:", table(def.args.map((a) => [` ${a.name}`, a.description])));
|
|
17
|
+
const own = [...(def.options ?? []), ...(def.writes ? WRITE_OPTIONS : [])];
|
|
18
|
+
if (own.length)
|
|
19
|
+
out.push("", "Options:", table(optionRows(own).map(([a, b]) => [` ${a}`, b ?? ""])));
|
|
20
|
+
out.push("", "Global options: --tree --json --limit --page --lang --yes --debug (all: strom help)");
|
|
21
|
+
return out.join("\n");
|
|
22
|
+
}
|
|
23
|
+
export function groupHelp(prefix) {
|
|
24
|
+
const subs = subcommandsOf(prefix);
|
|
25
|
+
return [
|
|
26
|
+
`strom ${prefix.join(" ")} <command>`,
|
|
27
|
+
"",
|
|
28
|
+
table(subs.map((c) => [` ${c.path.join(" ")}`, c.summary])),
|
|
29
|
+
"",
|
|
30
|
+
`Details: strom help ${prefix.join(" ")} <command>`,
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
/** Overview of all commands, grouped. */
|
|
34
|
+
export function overview() {
|
|
35
|
+
const out = ["strom <command> [options] (strom guide — how to do research with strom)", ""];
|
|
36
|
+
for (const [group, title] of Object.entries(GROUPS)) {
|
|
37
|
+
const defs = commands().filter((c) => c.group === group && c.path.length > 0);
|
|
38
|
+
if (defs.length === 0)
|
|
39
|
+
continue;
|
|
40
|
+
out.push(`${title}:`, table(defs.map((c) => [` ${c.path.join(" ")}`, c.summary])), "");
|
|
41
|
+
}
|
|
42
|
+
out.push("Global options:", table(optionRows(GLOBAL_OPTIONS).map(([a, b]) => [` ${a}`, b ?? ""])));
|
|
43
|
+
out.push("", "Help for one command: strom help <command> · machine-readable catalog: strom commands --json");
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
|
46
|
+
export function helpFor(input) {
|
|
47
|
+
// "strom help 'research new'" — one quoted argument with several words.
|
|
48
|
+
const words = input.flatMap((w) => w.trim().split(/\s+/)).filter(Boolean);
|
|
49
|
+
if (words.length === 0)
|
|
50
|
+
return overview();
|
|
51
|
+
const found = match(words);
|
|
52
|
+
if (found && found.used === words.length)
|
|
53
|
+
return commandHelp(found.def);
|
|
54
|
+
if (subcommandsOf(words).length > 0)
|
|
55
|
+
return groupHelp(words);
|
|
56
|
+
const all = commands().filter((c) => c.path.length > 0).map((c) => c.path.join(" "));
|
|
57
|
+
const near = suggest(words.join(" "), all.filter((c) => c.split(" ").length === words.length));
|
|
58
|
+
return `unknown command "${words.join(" ")}"${near.length ? ` — did you mean: ${near.map((c) => `strom help ${c}`).join(" · ")}` : ""}\nall commands: strom help`;
|
|
59
|
+
}
|