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.
Files changed (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,152 @@
1
+ // CLI entry: find the command, parse options, run it, print the result,
2
+ // and commit what a writing command changed. Returns the exit code.
3
+ import { EXIT, StromError, UsageError } from "../core/errors.js";
4
+ import { Context } from "./context.js";
5
+ import { groupHelp, helpFor } from "./help.js";
6
+ import { autoCommit } from "./commit.js";
7
+ import { assertIntact } from "../core/integrity.js";
8
+ import { resetCache } from "../core/git.js";
9
+ import { VERSION } from "../core/tree.js";
10
+ import { isAgent } from "../core/which.js";
11
+ import { noticeStromApp } from "../core/stromapp.js";
12
+ import { checkArgs, GroupOnly, parseOptions, resolveCommand, splitPassthrough } from "./execute.js";
13
+ import "../commands/index.js";
14
+ export { splitCommand } from "./execute.js";
15
+ /** Compact JSON: indentation costs an agent tokens and adds nothing for a parser. */
16
+ function toJson(value) {
17
+ return JSON.stringify(value) + "\n";
18
+ }
19
+ function print(io, ctx, result) {
20
+ if (ctx?.json)
21
+ io.stdout(toJson(result.data ?? { text: result.text }));
22
+ else if (result.text)
23
+ io.stdout(result.text.endsWith("\n") ? result.text : result.text + "\n");
24
+ }
25
+ /** File-system errors in plain words (a missing @file, a folder without rights). */
26
+ function systemError(e) {
27
+ const where = e.path ? `: ${e.path}` : "";
28
+ switch (e.code) {
29
+ case "ENOENT":
30
+ return new UsageError(`no such file or folder${where}`, { hint: "check the path (relative paths start in the current folder)" });
31
+ case "EACCES":
32
+ case "EPERM":
33
+ return new UsageError(`no permission to use${where}`, { hint: "choose a folder you can write to" });
34
+ case "EISDIR":
35
+ return new UsageError(`a folder was given where a file is expected${where}`);
36
+ case "ENOTDIR":
37
+ return new UsageError(`a file was given where a folder is expected${where}`);
38
+ case "ENOSPC":
39
+ return new StromError(`the disk is full${where}`);
40
+ }
41
+ return undefined;
42
+ }
43
+ function printError(io, json, err, debug) {
44
+ const e = err instanceof StromError ? err : (systemError(err) ?? err);
45
+ if (e instanceof StromError) {
46
+ if (json)
47
+ io.stdout(toJson(e.toJSON()));
48
+ else {
49
+ io.stderr(`error: ${e.message}\n`);
50
+ const cands = e.details?.candidates;
51
+ if (cands)
52
+ for (const c of cands)
53
+ io.stderr(` ${c.label}\n`);
54
+ if (e.hint)
55
+ for (const h of e.hint.split("\n"))
56
+ io.stderr(`→ ${h}\n`);
57
+ if (debug && err.stack)
58
+ io.stderr(err.stack + "\n");
59
+ }
60
+ return e.exitCode;
61
+ }
62
+ const x = e;
63
+ if (json)
64
+ io.stdout(toJson({ status: "error", message: x.message }));
65
+ else {
66
+ io.stderr(`error: ${x.message}\n`);
67
+ io.stderr(debug ? `${x.stack}\n` : "→ run again with --debug for details\n");
68
+ }
69
+ return EXIT.error;
70
+ }
71
+ export async function main(argv, io, env, cwd) {
72
+ const json = argv.includes("--json");
73
+ const debug = argv.includes("--debug");
74
+ resetCache();
75
+ try {
76
+ // "strom 'person list'" (one quoted word) means the same as strom person list.
77
+ if (argv[0] && !argv[0].startsWith("-") && /\s/.test(argv[0].trim()))
78
+ argv = [...argv[0].trim().split(/\s+/), ...argv.slice(1)];
79
+ // A person at a terminal gets the guided menu; an agent, a script or the app the orientation.
80
+ if (argv.length === 0 && io.tty && !isAgent(env) && env.STROM_NONINTERACTIVE !== "1")
81
+ argv = ["menu"];
82
+ if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) {
83
+ io.stdout(json ? toJson({ version: VERSION }) : `strom ${VERSION}\n`);
84
+ return EXIT.ok;
85
+ }
86
+ let resolved;
87
+ try {
88
+ resolved = resolveCommand(argv);
89
+ }
90
+ catch (err) {
91
+ if (err instanceof GroupOnly) {
92
+ io.stdout(groupHelp(err.group) + "\n");
93
+ return EXIT.ok;
94
+ }
95
+ throw err;
96
+ }
97
+ const { def } = resolved;
98
+ const { rest, passthrough } = def.passthrough ? splitPassthrough(resolved.rest) : { rest: resolved.rest, passthrough: [] };
99
+ const parsed = parseOptions(def, rest);
100
+ const v = parsed.values;
101
+ if (v.help) {
102
+ io.stdout(helpFor(def.path) + "\n");
103
+ return EXIT.ok;
104
+ }
105
+ if (v.version) {
106
+ io.stdout(json ? toJson({ version: VERSION }) : `strom ${VERSION}\n`);
107
+ return EXIT.ok;
108
+ }
109
+ const ctx = Context.fromOptions({ env, cwd, io, json, values: v });
110
+ // Started by the Strom app: remembered quietly (it is where the results go).
111
+ if (env.STROM_APP)
112
+ noticeStromApp(ctx.settings, env);
113
+ const args = parsed.positionals;
114
+ checkArgs(def, args);
115
+ if (def.writes && v["dry-run"])
116
+ ctx.dryRun = true;
117
+ const work = async () => {
118
+ // Never write on top of data that was changed outside strom.
119
+ if (def.writes && def.tree)
120
+ assertIntact(ctx.tree());
121
+ let result;
122
+ try {
123
+ result = await def.run(ctx, { args, opts: v, ...(passthrough.length ? { extra: passthrough } : {}) });
124
+ }
125
+ catch (err) {
126
+ // A writing command is a transaction: on failure nothing it wrote remains.
127
+ const t = def.writes ? ctx.current() : undefined;
128
+ if (t)
129
+ t.withTreeLock(() => t.rollback());
130
+ throw err;
131
+ }
132
+ if (def.writes) {
133
+ const blocked = autoCommit(ctx, def);
134
+ if (blocked) {
135
+ // The agent must notice: an uncommitted write is not part of the research yet.
136
+ result.text = result.text ? `${result.text}\n${blocked.message}` : blocked.message;
137
+ result.data = { ...(result.data ?? {}), status: "not-committed", problems: blocked.problems, hint: "strom check" };
138
+ result.exitCode = EXIT.error;
139
+ }
140
+ }
141
+ return result;
142
+ };
143
+ // Other strom processes may write to the same tree (other agents, a run): a writing
144
+ // command has the tree to itself from its first read to its commit.
145
+ const result = def.writes && def.tree && def.lock !== "sections" ? await ctx.tree().holdTreeLock(work) : await work();
146
+ print(io, ctx, result);
147
+ return result.exitCode ?? EXIT.ok;
148
+ }
149
+ catch (err) {
150
+ return printError(io, json, err, debug);
151
+ }
152
+ }
@@ -0,0 +1,212 @@
1
+ // The guided menu: what a person gets from `strom` at a terminal (or the
2
+ // shortcut on the desktop). In their language, numbered, Enter takes the
3
+ // first choice — continuing the research in a conversation with the agent,
4
+ // which is how the research is done. Every item runs an ordinary command, so
5
+ // nothing here is a second way of doing things.
6
+ //
7
+ // First time: the setup wizard, a name for the family tree, and straight into
8
+ // the first conversation with the agent.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { ui } from "./ui.js";
12
+ import { Tree, VERSION } from "../core/tree.js";
13
+ import { liveHolder } from "../core/lock.js";
14
+ import { newerVersion } from "../core/update.js";
15
+ import { waitingForUser } from "../commands/tasks.js";
16
+ import { noticeStromApp, stromAppState } from "../core/stromapp.js";
17
+ import { openForUser } from "../core/open.js";
18
+ import { AGENTS, findAgent } from "../core/which.js";
19
+ import { agentsHere } from "../core/apps.js";
20
+ import { PROFILES } from "../agents/profiles.js";
21
+ /** Create a family tree and say so in the user's words (init itself talks to agents). */
22
+ async function createTree(ctx, run, lang, name) {
23
+ const before = new Set(ctx.knownTrees().map((k) => k.root));
24
+ if ((await run(["init", name], true)) !== 0)
25
+ return undefined;
26
+ const made = ctx.knownTrees().find((k) => !before.has(k.root));
27
+ if (made)
28
+ ctx.io.stdout(ui(lang, "ui.tree.created", { name: made.name, dir: ctx.display(made.root) }) + "\n");
29
+ return made?.root;
30
+ }
31
+ export async function runMenu(ctx, run) {
32
+ const out = (line = "") => ctx.io.stdout(line + "\n");
33
+ const outOfAnswers = () => ctx.io.answers !== undefined && ctx.io.answers.length === 0;
34
+ const reload = () => ctx.settings.reload();
35
+ const pause = async (lang) => {
36
+ if (!outOfAnswers())
37
+ await ctx.ask(ui(lang, "ui.enter"));
38
+ };
39
+ // First time on this computer: the wizard.
40
+ if (!ctx.settings.home()) {
41
+ await run(["setup"]);
42
+ reload();
43
+ if (!ctx.settings.home())
44
+ return;
45
+ }
46
+ noticeStromApp(ctx.settings, ctx.env, { look: true });
47
+ for (;;) {
48
+ reload();
49
+ const lang = ctx.uiLang();
50
+ const t = (key, values = {}) => ui(lang, key, values);
51
+ const known = ctx.knownTrees();
52
+ let root;
53
+ try {
54
+ root = ctx.locateTree();
55
+ }
56
+ catch {
57
+ root = undefined;
58
+ }
59
+ // No family tree yet: name it, create it, and start the research.
60
+ if (!root && known.length === 0) {
61
+ if (outOfAnswers())
62
+ return;
63
+ const name = await ctx.ask(t("ui.tree.name"), t("ui.tree.default"));
64
+ if (!(await createTree(ctx, run, lang, name)))
65
+ return;
66
+ reload();
67
+ await run(["chat"]);
68
+ if (outOfAnswers())
69
+ return;
70
+ continue;
71
+ }
72
+ const items = [];
73
+ out();
74
+ out(t("ui.menu.title", { version: VERSION }));
75
+ const newer = await newerVersion(ctx.settings, ctx.env);
76
+ if (newer)
77
+ out(t("ui.menu.update.line", { version: newer }));
78
+ if (root) {
79
+ const tree = Tree.open(root, ctx.env);
80
+ const waiting = waitingForUser(tree).length;
81
+ const started = tree.list("research").length > 0;
82
+ out(t("ui.menu.tree", { name: tree.config.name, persons: tree.count("person") }));
83
+ if (waiting)
84
+ out(t("ui.menu.waiting", { count: waiting }));
85
+ if (liveHolder(path.join(root, ".strom", "run.lock")))
86
+ out(t("ui.menu.working"));
87
+ items.push({ key: "1", label: t(started ? "ui.menu.chat" : "ui.menu.start"), act: async () => void (await run(["chat"])) }, {
88
+ key: "2",
89
+ label: t("ui.menu.run"),
90
+ act: async () => {
91
+ // Working alone is the agent's CLI; with only its app on this computer, strom says so.
92
+ const who = PROFILES[ctx.settings.agent(tree.config).value];
93
+ if (who && !findAgent(who.command, ctx.env)) {
94
+ out(t("ui.run.needscli", { agent: who.name }));
95
+ await pause(lang);
96
+ return;
97
+ }
98
+ let n = 0;
99
+ while (!(Number.isInteger(n) && n > 0 && n <= 100)) {
100
+ if (outOfAnswers())
101
+ return;
102
+ n = Number(await ctx.ask(t("ui.run.how"), "3"));
103
+ }
104
+ out(t("ui.run.start"));
105
+ await run(["run", "--max", String(n)]);
106
+ await pause(lang);
107
+ },
108
+ }, {
109
+ key: "3",
110
+ label: `${t("ui.menu.waitlist")}${waiting ? ` (${waiting})` : ""}`,
111
+ act: async () => {
112
+ if (!waiting)
113
+ out(t("ui.waiting.none"));
114
+ else {
115
+ await run(["task", "list", "--state", "waiting"]);
116
+ const inbox = ctx.settings.shared() ? path.join(ctx.settings.shared().value, "inbox") : undefined;
117
+ if (inbox && (await ctx.confirm(t("ui.waiting.open"), false)))
118
+ openForUser(inbox, ctx.env);
119
+ }
120
+ await pause(lang);
121
+ },
122
+ }, {
123
+ key: "4",
124
+ label: t("ui.menu.results"),
125
+ act: async () => {
126
+ const outDir = path.join(root, "output");
127
+ const geds = ["tree.ged", "tree-strom.ged"].map((f) => path.join(outDir, f)).filter((f) => fs.existsSync(f));
128
+ if (!geds.length)
129
+ out(t("ui.results.none"));
130
+ else {
131
+ out(t("ui.results.files"));
132
+ for (const g of geds)
133
+ out(` ${ctx.display(g)}`);
134
+ const strom = geds.find((g) => g.endsWith("tree-strom.ged"));
135
+ const app = stromAppState(ctx.settings);
136
+ if (strom && (app === "yes" || app === "seen"))
137
+ out(t("ui.results.app", { file: ctx.display(strom) }));
138
+ else if (strom && app === "unknown")
139
+ out(t("ui.results.app.maybe", { file: ctx.display(strom) }));
140
+ if (await ctx.confirm(t("ui.results.open"), false))
141
+ openForUser(outDir, ctx.env);
142
+ }
143
+ await pause(lang);
144
+ },
145
+ });
146
+ // Several agents on this computer: this conversation with another one (the default stays).
147
+ const current = ctx.settings.agent(tree.config).value;
148
+ const others = agentsHere(ctx.env)
149
+ .filter((a) => a.id !== current)
150
+ .map((a) => AGENTS.find((x) => x.id === a.id));
151
+ if (others.length)
152
+ items.push({
153
+ key: "9",
154
+ label: t("ui.menu.otheragent"),
155
+ act: async () => {
156
+ const i = others.length === 1 ? 0 : await ctx.choose(t("ui.setup.agent.pick"), others.map((a) => ({ label: a.name })), 0);
157
+ if (i !== undefined)
158
+ await run(["chat", "--agent", others[i].id]);
159
+ },
160
+ });
161
+ if (stromAppState(ctx.settings) !== "no")
162
+ items.push({ key: "5", label: t("ui.menu.app"), act: async () => void (await run(["app"])) });
163
+ }
164
+ else {
165
+ // Several trees and none chosen: pick one first.
166
+ items.push({ key: "1", label: t("ui.menu.trees"), act: async () => pickTree(ctx, run, lang) });
167
+ }
168
+ items.push({ key: "6", label: t("ui.menu.settings"), act: async () => void (await run(["setup"])) }, {
169
+ key: "7",
170
+ label: t("ui.menu.doctor"),
171
+ act: async () => {
172
+ const code = await run(["doctor"]);
173
+ if (code !== 0 && (await ctx.confirm(t("ui.menu.fix"), true)))
174
+ await run(["doctor", "--fix"]);
175
+ await pause(lang);
176
+ },
177
+ });
178
+ if (newer)
179
+ items.push({ key: "9", label: t("ui.menu.update", { version: newer }), act: async () => void (await run(["update"])) });
180
+ if (root)
181
+ items.push({ key: "8", label: known.length > 1 ? t("ui.menu.trees") : t("ui.menu.newtree"), act: async () => pickTree(ctx, run, lang) });
182
+ items.push({ key: "0", label: t("ui.menu.quit"), act: async () => true });
183
+ // Numbered in the order shown; 0 always quits.
184
+ let n = 0;
185
+ const i = await ctx.choose("", items.map((it) => ({ key: it.key === "0" ? "0" : String(++n), label: it.label })), 0);
186
+ if (i === undefined)
187
+ return;
188
+ const quit = await items[i].act();
189
+ if (quit === true) {
190
+ out(t("ui.menu.bye"));
191
+ return;
192
+ }
193
+ }
194
+ }
195
+ /** Choose another family tree, or create a new one (and start its research). */
196
+ async function pickTree(ctx, run, lang) {
197
+ const known = ctx.knownTrees();
198
+ const options = [...known.map((k) => ({ label: `${k.name} (${ctx.display(k.root)})` })), { label: ui(lang, "ui.trees.new") }];
199
+ const i = await ctx.choose(ui(lang, "ui.trees.pick"), options, 0);
200
+ if (i === undefined)
201
+ return;
202
+ if (i < known.length) {
203
+ await run(["trees", "use", known[i].root], true);
204
+ return;
205
+ }
206
+ const name = await ctx.ask(ui(lang, "ui.tree.name"), ui(lang, "ui.tree.default"));
207
+ const made = await createTree(ctx, run, lang, name);
208
+ if (!made)
209
+ return;
210
+ await run(["trees", "use", made], true);
211
+ await run(["chat"]);
212
+ }
@@ -0,0 +1,96 @@
1
+ // The command registry: the single source of truth for every command.
2
+ // Help, `strom commands --json`, `strom guide` and argument parsing are all
3
+ // derived from these declarations.
4
+ export const GROUPS = {
5
+ start: "Orientation",
6
+ research: "Trees and researches",
7
+ inputs: "Inputs (what the research starts from)",
8
+ tasks: "Tasks",
9
+ people: "People, families, facts",
10
+ sources: "Sources, archives, record sets, places",
11
+ analysis: "Searches, conflicts, hypotheses, lessons",
12
+ output: "Output (GEDCOM)",
13
+ history: "Checks and history",
14
+ setup: "Setup and environment",
15
+ };
16
+ export const GLOBAL_OPTIONS = [
17
+ { name: "tree", type: "string", value: "<dir|name>", description: "tree to work on (default: STROM_TREE, the tree you are in, or the only tree)" },
18
+ { name: "json", type: "boolean", description: "machine-readable output" },
19
+ { name: "limit", type: "string", value: "<n>", description: "max rows in listings (default 50)" },
20
+ { name: "page", type: "string", value: "<n>", description: "page of a listing" },
21
+ { name: "lang", type: "string", value: "<code>", description: "research language, e.g. cs, en, de" },
22
+ { name: "home", type: "string", value: "<dir>", description: "Strom home folder (default from config)" },
23
+ { name: "shared", type: "string", value: "<dir>", description: "shared data folder (media, catalog, tools)" },
24
+ { name: "trees", type: "string", value: "<dir>", description: "folder holding the trees" },
25
+ { name: "agent", type: "string", value: "<agent>", description: "AI agent: claude, codex, antigravity, opencode (default from config)" },
26
+ { name: "yes", type: "boolean", description: "accept suggested defaults, never ask" },
27
+ { name: "debug", type: "boolean", description: "show technical details on errors" },
28
+ { name: "version", type: "boolean", description: "print the version of strom" },
29
+ { name: "help", type: "boolean", short: "h", description: "help for a command" },
30
+ ];
31
+ export const WRITE_OPTIONS = [
32
+ { name: "dry-run", type: "boolean", description: "show what would change, change nothing" },
33
+ { name: "reason", type: "string", value: "<text>", description: "why (required when changing or retracting facts)" },
34
+ ];
35
+ const registry = [];
36
+ export function register(...defs) {
37
+ for (const def of defs) {
38
+ if (registry.some((c) => c.path.join(" ") === def.path.join(" ")))
39
+ throw new Error(`duplicate command ${def.path.join(" ")}`);
40
+ registry.push(def);
41
+ }
42
+ }
43
+ export function commands() {
44
+ return registry;
45
+ }
46
+ export function optionsOf(def) {
47
+ return [...(def.options ?? []), ...(def.writes ? WRITE_OPTIONS : []), ...GLOBAL_OPTIONS];
48
+ }
49
+ /** Longest registered command path matching the leading words. */
50
+ export function match(words) {
51
+ let best;
52
+ for (const def of registry) {
53
+ const n = def.path.length;
54
+ if (n > words.length)
55
+ continue;
56
+ if (n === 0 && words.length > 0)
57
+ continue; // the root command only matches no words
58
+ if (def.path.every((w, i) => words[i] === w) && (!best || n > best.used))
59
+ best = { def, used: n };
60
+ }
61
+ return best;
62
+ }
63
+ /** Command groups ("person") that have subcommands but no command of their own. */
64
+ export function subcommandsOf(prefix) {
65
+ return registry.filter((c) => c.path.length > prefix.length && prefix.every((w, i) => c.path[i] === w));
66
+ }
67
+ export function usageLine(def) {
68
+ const args = (def.args ?? []).map((a) => {
69
+ const name = a.variadic ? `${a.name}...` : a.name;
70
+ return a.required ? `<${name}>` : `[${name}]`;
71
+ });
72
+ return ["strom", ...def.path, ...args, (def.options?.length || def.writes) ? "[options]" : ""].filter(Boolean).join(" ");
73
+ }
74
+ /** Catalog entry for `strom commands --json`: only what is there (empty fields are left out). */
75
+ export function describe(def) {
76
+ const out = { command: def.path.join(" "), usage: usageLine(def), summary: def.summary, group: def.group };
77
+ if (def.description)
78
+ out.description = def.description;
79
+ if (def.writes)
80
+ out.writes = true;
81
+ if (def.tree)
82
+ out.needsTree = true;
83
+ if (def.args?.length)
84
+ out.args = def.args;
85
+ const opts = [...(def.options ?? []), ...(def.writes ? WRITE_OPTIONS : [])];
86
+ if (opts.length)
87
+ out.options = opts.map((o) => ({
88
+ name: `--${o.name}`,
89
+ ...(o.type === "boolean" ? { flag: true } : { value: o.value ?? "<value>" }),
90
+ ...(o.multiple ? { repeatable: true } : {}),
91
+ description: o.description,
92
+ }));
93
+ if (def.examples?.length)
94
+ out.examples = def.examples;
95
+ return out;
96
+ }