@tiagojct/subsub 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +90 -0
  2. package/bin/subsub-bench.js +19 -0
  3. package/bin/subsub.js +14 -0
  4. package/dist/args.js +67 -0
  5. package/dist/bench.js +534 -0
  6. package/dist/bridge.js +107 -0
  7. package/dist/cli.js +209 -0
  8. package/dist/config.js +144 -0
  9. package/dist/doctor.js +131 -0
  10. package/dist/init.js +215 -0
  11. package/dist/look.js +107 -0
  12. package/dist/paths.js +86 -0
  13. package/dist/preview.js +94 -0
  14. package/dist/profiles.js +79 -0
  15. package/dist/prompt.js +72 -0
  16. package/dist/roles.js +73 -0
  17. package/dist/subsub.js +414 -0
  18. package/docs/Subsub.md +126 -0
  19. package/extensions/subsub.ts +6 -0
  20. package/package.json +73 -0
  21. package/prompts/alert.md +4 -0
  22. package/prompts/clean-tags.md +4 -0
  23. package/prompts/gaps.md +5 -0
  24. package/prompts/import-queue.md +5 -0
  25. package/prompts/lit-note.md +5 -0
  26. package/prompts/manuscript.md +5 -0
  27. package/prompts/synthesis.md +5 -0
  28. package/prompts/tag-batch.md +5 -0
  29. package/roles/librarian.md +46 -0
  30. package/roles/researcher.md +37 -0
  31. package/scripts/build.mjs +14 -0
  32. package/scripts/make-themes.py +116 -0
  33. package/src/args.ts +64 -0
  34. package/src/bench.ts +580 -0
  35. package/src/bridge.ts +134 -0
  36. package/src/cli.ts +201 -0
  37. package/src/config.ts +191 -0
  38. package/src/doctor.ts +171 -0
  39. package/src/init.ts +252 -0
  40. package/src/look.ts +141 -0
  41. package/src/paths.ts +100 -0
  42. package/src/preview.ts +107 -0
  43. package/src/profiles.ts +99 -0
  44. package/src/prompt.ts +81 -0
  45. package/src/roles.ts +83 -0
  46. package/src/subsub.ts +434 -0
  47. package/templates/Zotero agent.md +87 -0
  48. package/templates/vocabularies/any-field.md +52 -0
  49. package/templates/vocabularies/health-informatics.md +88 -0
  50. package/templates/vocabularies/health-sciences.md +95 -0
  51. package/themes/subsub-glauca-dark.json +91 -0
  52. package/themes/subsub-glauca-light.json +91 -0
  53. package/themes/subsub-try-works-dark.json +91 -0
  54. package/themes/subsub-try-works-light.json +91 -0
package/src/bridge.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Bridge to the Python MCP servers of zotero-local-mcp (librarian "zotero" and
3
+ * researcher "scholar"). Starts them over stdio, lists their tools, and calls them.
4
+ * All safety logic (dry runs, version checks, journal, vocabulary, the researcher's
5
+ * note-only client) stays in the Python servers.
6
+ */
7
+
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+
11
+ export interface ServerSpec {
12
+ name: string; // tool prefix: "zotero" or "scholar"
13
+ command: string;
14
+ args: string[];
15
+ cwd?: string;
16
+ env?: Record<string, string>;
17
+ }
18
+
19
+ export interface BridgeTool {
20
+ server: string;
21
+ name: string; // MCP tool name
22
+ fullName: string; // prefixed name used in pi, e.g. zotero_find_items
23
+ description: string;
24
+ inputSchema: Record<string, unknown>;
25
+ }
26
+
27
+ export interface CallResult {
28
+ isError: boolean;
29
+ text: string;
30
+ data?: unknown;
31
+ }
32
+
33
+ function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
34
+ return new Promise((resolve, reject) => {
35
+ const t = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s`)), ms);
36
+ p.then(
37
+ (v) => {
38
+ clearTimeout(t);
39
+ resolve(v);
40
+ },
41
+ (e) => {
42
+ clearTimeout(t);
43
+ reject(e);
44
+ },
45
+ );
46
+ });
47
+ }
48
+
49
+ export class Bridge {
50
+ private clients = new Map<string, Client>();
51
+ private owner = new Map<string, { server: string; name: string }>();
52
+ tools: BridgeTool[] = [];
53
+ errors: Record<string, string> = {};
54
+
55
+ async start(specs: ServerSpec[], timeoutMs = 45_000): Promise<void> {
56
+ await Promise.all(specs.map((s) => this.startOne(s, timeoutMs)));
57
+ }
58
+
59
+ private async startOne(spec: ServerSpec, timeoutMs: number): Promise<void> {
60
+ const transport = new StdioClientTransport({
61
+ command: spec.command,
62
+ args: spec.args,
63
+ cwd: spec.cwd,
64
+ env: { ...(process.env as Record<string, string>), ...(spec.env ?? {}) },
65
+ stderr: "pipe",
66
+ });
67
+ const client = new Client({ name: "subsub", version: "0.1.0" });
68
+ let stderr = "";
69
+ transport.stderr?.on("data", (chunk: Buffer) => {
70
+ stderr = (stderr + chunk.toString()).slice(-2000);
71
+ });
72
+ try {
73
+ await withTimeout(client.connect(transport), timeoutMs, `The ${spec.name} server`);
74
+ const listed = await withTimeout(client.listTools(), timeoutMs, `The ${spec.name} server`);
75
+ this.clients.set(spec.name, client);
76
+ for (const t of listed.tools) {
77
+ const fullName = `${spec.name}_${t.name}`;
78
+ this.owner.set(fullName, { server: spec.name, name: t.name });
79
+ this.tools.push({
80
+ server: spec.name,
81
+ name: t.name,
82
+ fullName,
83
+ description: t.description ?? "",
84
+ inputSchema: (t.inputSchema as Record<string, unknown>) ?? { type: "object", properties: {} },
85
+ });
86
+ }
87
+ } catch (err) {
88
+ this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
89
+ try {
90
+ await client.close();
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ }
95
+ }
96
+
97
+ has(fullName: string): boolean {
98
+ return this.owner.has(fullName);
99
+ }
100
+
101
+ async call(fullName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<CallResult> {
102
+ const own = this.owner.get(fullName);
103
+ if (!own) return { isError: true, text: `Unknown tool ${fullName}` };
104
+ const client = this.clients.get(own.server);
105
+ if (!client) return { isError: true, text: `The ${own.server} server is not running.` };
106
+ const res = await client.callTool({ name: own.name, arguments: args }, undefined, {
107
+ signal,
108
+ timeout: 15 * 60_000, // authorization dialogs and PDF downloads can take minutes
109
+ resetTimeoutOnProgress: true,
110
+ });
111
+ const blocks = (res.content as Array<{ type: string; text?: string }>) ?? [];
112
+ const text = blocks
113
+ .filter((b) => b.type === "text")
114
+ .map((b) => b.text ?? "")
115
+ .join("\n");
116
+ let data: unknown = undefined;
117
+ const sc = res.structuredContent as Record<string, unknown> | undefined;
118
+ if (sc && typeof sc === "object") {
119
+ data = Object.keys(sc).length === 1 && "result" in sc ? sc.result : sc;
120
+ } else {
121
+ try {
122
+ data = JSON.parse(text);
123
+ } catch {
124
+ /* plain text */
125
+ }
126
+ }
127
+ return { isError: Boolean(res.isError), text: text || JSON.stringify(data ?? ""), data };
128
+ }
129
+
130
+ async close(): Promise<void> {
131
+ await Promise.all([...this.clients.values()].map((c) => c.close().catch(() => undefined)));
132
+ this.clients.clear();
133
+ }
134
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The `subsub` command: pi, set up as Sub-Sub.
3
+ *
4
+ * - Own pi folder (~/.subsub/agent, or SUBSUB_AGENT_DIR): settings, sessions
5
+ * and packages are separate from plain pi.
6
+ * - The Sub-Sub package is declared in that folder's settings.json, so pi
7
+ * loads the extension and the prompt templates in the normal way.
8
+ * - Credentials: if pi already has an auth.json and Sub-Sub has none, Sub-Sub
9
+ * links to it, so one /login serves both.
10
+ * - Folder: started from the home folder, Sub-Sub moves to the vault.
11
+ * `--here` keeps the current folder.
12
+ * - `subsub review preview|apply NOTE...` runs zotero-review (tag review notes,
13
+ * several at a time) with Sub-Sub's server folder and settings.
14
+ * - `subsub init` sets Sub-Sub up; `subsub doctor` checks the setup.
15
+ */
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+ import { expand, loadConfig, serverCommand, type SubsubConfig } from "./config.ts";
23
+
24
+ export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+
26
+ export function packageVersion(dir: string = PACKAGE_DIR): string {
27
+ try {
28
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version ?? "0.0.0";
29
+ } catch {
30
+ return "0.0.0";
31
+ }
32
+ }
33
+
34
+ export function agentDirFor(env: NodeJS.ProcessEnv, home: string = homedir()): string {
35
+ return env.SUBSUB_AGENT_DIR ? expand(env.SUBSUB_AGENT_DIR) : join(home, ".subsub", "agent");
36
+ }
37
+
38
+ function real(p: string): string {
39
+ try {
40
+ return realpathSync(p);
41
+ } catch {
42
+ return resolve(p);
43
+ }
44
+ }
45
+
46
+ function sourceOf(entry: unknown): string | undefined {
47
+ if (typeof entry === "string") return entry;
48
+ if (entry && typeof entry === "object" && typeof (entry as { source?: unknown }).source === "string") {
49
+ return (entry as { source: string }).source;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ /**
55
+ * Make sure settings.json in the agent folder declares the Sub-Sub package.
56
+ * Other settings are kept. Returns what happened, for tests and messages.
57
+ */
58
+ export function ensureSettings(agentDir: string, packageDir: string = PACKAGE_DIR): "created" | "added" | "present" | "unreadable" {
59
+ mkdirSync(agentDir, { recursive: true });
60
+ const file = join(agentDir, "settings.json");
61
+ let settings: Record<string, unknown> = {};
62
+ let existed = false;
63
+ if (existsSync(file)) {
64
+ existed = true;
65
+ try {
66
+ settings = JSON.parse(readFileSync(file, "utf8"));
67
+ } catch {
68
+ return "unreadable";
69
+ }
70
+ }
71
+ const packages = Array.isArray(settings.packages) ? [...settings.packages] : [];
72
+ const want = real(packageDir);
73
+ const has = packages.some((p) => {
74
+ const src = sourceOf(p);
75
+ return src !== undefined && real(resolve(agentDir, expand(src))) === want;
76
+ });
77
+ // Sub-Sub draws its own header; pi's list of loaded resources is noise here. Set once, so it can be changed.
78
+ const quiet = settings.quietStartup === undefined;
79
+ if (has && !quiet) return "present";
80
+ if (!has) packages.push(want);
81
+ settings.packages = packages;
82
+ if (quiet) settings.quietStartup = true;
83
+ writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
84
+ return existed ? "added" : "created";
85
+ }
86
+
87
+ function isEmptyAuth(file: string): boolean {
88
+ try {
89
+ const data = JSON.parse(readFileSync(file, "utf8"));
90
+ return data && typeof data === "object" && Object.keys(data).length === 0;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** Link Sub-Sub's auth.json to pi's, when pi has one and Sub-Sub has none. */
97
+ export function shareAuth(agentDir: string, piAgentDir: string): boolean {
98
+ const mine = join(agentDir, "auth.json");
99
+ const theirs = join(piAgentDir, "auth.json");
100
+ if (!existsSync(theirs)) return false;
101
+ try {
102
+ const st = lstatSync(mine);
103
+ // pi writes an empty "{}" on first start; that one may be replaced.
104
+ if (st.isSymbolicLink() || !isEmptyAuth(mine)) return false;
105
+ renameSync(mine, `${mine}.empty`);
106
+ } catch (err) {
107
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
108
+ }
109
+ mkdirSync(agentDir, { recursive: true });
110
+ symlinkSync(theirs, mine);
111
+ return true;
112
+ }
113
+
114
+ /** Folder to start in: the vault when started from the home folder, unless --here. */
115
+ export function chooseCwd(cwd: string, home: string, vault: string | undefined, here: boolean): string {
116
+ if (here || !vault || !existsSync(vault)) return cwd;
117
+ return real(cwd) === real(home) ? vault : cwd;
118
+ }
119
+
120
+ /** `pi update` with no package source updates pi itself; here that must not happen. */
121
+ export function isSelfUpdate(args: string[]): boolean {
122
+ if (args[0] !== "update") return false;
123
+ const rest = args.slice(1).filter((a) => a !== "--force" && a !== "-a" && a !== "--approve");
124
+ return rest.length === 0 || rest.some((a) => ["--self", "self", "pi", "--all"].includes(a));
125
+ }
126
+
127
+ /** The command line for `subsub review ...`: zotero-review in the server folder, with the same settings file. */
128
+ export function reviewCommand(args: string[], cfg: SubsubConfig, env: NodeJS.ProcessEnv = process.env): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
129
+ return {
130
+ ...serverCommand(cfg, "zotero-review", args, env),
131
+ env: { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) },
132
+ };
133
+ }
134
+
135
+ export async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
136
+ const env = process.env;
137
+ const home = homedir();
138
+ const here = argv.includes("--here");
139
+ const args = argv.filter((a) => a !== "--here");
140
+
141
+ if (args[0] === "--version" || args[0] === "-v") {
142
+ const piPkg = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..");
143
+ console.log(`subsub ${packageVersion()} (pi ${packageVersion(piPkg)})`);
144
+ return;
145
+ }
146
+ if (args[0] === "init") {
147
+ const { initMain } = await import("./init.ts");
148
+ process.exitCode = await initMain(args.slice(1), env);
149
+ return;
150
+ }
151
+ if (args[0] === "doctor") {
152
+ const { doctorMain } = await import("./doctor.ts");
153
+ process.exitCode = await doctorMain(env);
154
+ return;
155
+ }
156
+ if (args[0] === "review") {
157
+ const r = reviewCommand(args.slice(1), loadConfig(env), env);
158
+ const res = spawnSync(r.command, r.args, { env: r.env, stdio: "inherit" });
159
+ if (res.error) console.error(`Sub-Sub: cannot run ${r.command}: ${res.error.message}`);
160
+ process.exitCode = res.status ?? 1;
161
+ return;
162
+ }
163
+ if (isSelfUpdate(args)) {
164
+ console.log(`Sub-Sub is not updated with "update". Its code is in ${PACKAGE_DIR}; after new code arrives, run "npm install" there.`);
165
+ console.log('To update packages added to Sub-Sub, use "subsub update --extensions".');
166
+ process.exitCode = 1;
167
+ return;
168
+ }
169
+
170
+ const agentDir = agentDirFor(env, home);
171
+ const piAgentDir = env.PI_CODING_AGENT_DIR ? expand(env.PI_CODING_AGENT_DIR) : join(home, ".pi", "agent");
172
+ const settings = ensureSettings(agentDir);
173
+ if (settings === "unreadable") {
174
+ console.error(`Sub-Sub: ${join(agentDir, "settings.json")} is not valid JSON. Fix it, or move it away and start again.`);
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ try {
179
+ shareAuth(agentDir, piAgentDir);
180
+ } catch {
181
+ /* not important: /login in Sub-Sub also works */
182
+ }
183
+
184
+ let vault: string | undefined;
185
+ try {
186
+ vault = loadConfig(env).vault;
187
+ } catch {
188
+ vault = undefined; // the extension reports config errors when it starts
189
+ }
190
+ const cwd = chooseCwd(process.cwd(), home, vault, here);
191
+ if (cwd !== process.cwd()) process.chdir(cwd);
192
+
193
+ env.PI_CODING_AGENT_DIR = agentDir;
194
+ env.SUBSUB_CLI = "1";
195
+ // pi's update notice would point to the global pi, not to this copy.
196
+ env.PI_SKIP_VERSION_CHECK ??= "1";
197
+
198
+ process.argv = [process.argv[0], process.argv[1], ...args];
199
+ const index = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
200
+ await import(pathToFileURL(join(dirname(index), "bundle", "cli.js")).href);
201
+ }
package/src/config.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Settings for Sub-Sub. Everything has a default; an optional JSON file
3
+ * (~/.config/subsub/config.json, or SUBSUB_CONFIG) overrides it.
4
+ *
5
+ * {
6
+ * "profile": "scholar",
7
+ * "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
8
+ * "vault": "~/Notes",
9
+ * "models": { "librarian": "opencode-go/glm-5.3-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
10
+ * "defaultMode": "researcher"
11
+ * }
12
+ *
13
+ * `subsub init` writes this file. Without "serverDir" (or when that folder does
14
+ * not exist), the Zotero servers run from PyPI with uv.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+
21
+ export type Mode = "librarian" | "researcher";
22
+ export type Profile = "reader" | "scholar" | "author" | "editor";
23
+ export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
24
+
25
+ /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
26
+ export const SERVER_VERSION = "0.4.1";
27
+
28
+ export interface SubsubConfig {
29
+ /** The settings file (written by `subsub init` and `/profile`). */
30
+ configFile: string;
31
+ /** How much Sub-Sub does for the user: reader, scholar, author or editor. */
32
+ profile: Profile;
33
+ /** How the prompts name the user ("the user" when not set). */
34
+ userName?: string;
35
+ /** One line about the user, e.g. "a master's student in health informatics". */
36
+ about?: string;
37
+ /** Language of replies and notes. "auto": the language the user writes in. */
38
+ language: string;
39
+ /** "fmup" when set up with the FMUP / U.Porto option. */
40
+ setup?: string;
41
+ serverDir: string;
42
+ envFile?: string;
43
+ vault?: string;
44
+ models: Partial<Record<Mode, string>>;
45
+ defaultMode: Mode;
46
+ /** Extra context files added to the system prompt when pi runs inside the vault. */
47
+ vaultContext: string[];
48
+ /** Shared rules file (tag system, citekeys, note formats). */
49
+ sharedRules?: string;
50
+ /** Seconds to wait for the Python servers to start. */
51
+ startTimeout: number;
52
+ /** Path to uv when it is not on PATH (for example when pi starts from a GUI). */
53
+ uvPath?: string;
54
+ /** Banner, status line and themes when Sub-Sub runs as its own command (default true). */
55
+ look?: boolean;
56
+ /** A line from Moby-Dick under the banner (default true). */
57
+ quotes?: boolean;
58
+ /** Theme per mode, without -dark/-light; false keeps your pi theme. */
59
+ themes?: Partial<Record<Mode, string>> | false;
60
+ }
61
+
62
+ export function expand(p: string): string {
63
+ return resolve(p.replace(/^~(?=$|\/)/, homedir()));
64
+ }
65
+
66
+ export function parseEnvFile(path: string): Record<string, string> {
67
+ const out: Record<string, string> = {};
68
+ let text: string;
69
+ try {
70
+ text = readFileSync(path, "utf8");
71
+ } catch {
72
+ return out;
73
+ }
74
+ for (const raw of text.split("\n")) {
75
+ const line = raw.trim();
76
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
77
+ const i = line.indexOf("=");
78
+ out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^['"]|['"]$/g, "");
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export function configPath(env: NodeJS.ProcessEnv = process.env): string {
84
+ return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
85
+ }
86
+
87
+ export function readConfigFile(file: string): Record<string, unknown> {
88
+ if (!existsSync(file)) return {};
89
+ try {
90
+ return JSON.parse(readFileSync(file, "utf8"));
91
+ } catch (err) {
92
+ throw new Error(`Sub-Sub: cannot read ${file}: ${(err as Error).message}`);
93
+ }
94
+ }
95
+
96
+ /** Merge settings into the settings file; other settings stay. */
97
+ export function saveConfig(patch: Record<string, unknown>, env: NodeJS.ProcessEnv = process.env): string {
98
+ const file = configPath(env);
99
+ const merged = { ...readConfigFile(file), ...patch };
100
+ for (const [k, v] of Object.entries(merged)) if (v === undefined) delete merged[k];
101
+ mkdirSync(dirname(file), { recursive: true });
102
+ writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`);
103
+ return file;
104
+ }
105
+
106
+ export function isProfile(x: unknown): x is Profile {
107
+ return typeof x === "string" && (PROFILES as string[]).includes(x);
108
+ }
109
+
110
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
111
+ const file = configPath(env);
112
+ let user: Partial<SubsubConfig> = {};
113
+ if (existsSync(file)) {
114
+ try {
115
+ user = JSON.parse(readFileSync(file, "utf8"));
116
+ } catch (err) {
117
+ throw new Error(`Sub-Sub: cannot read ${file}: ${(err as Error).message}`);
118
+ }
119
+ }
120
+ const envCandidates = [
121
+ user.envFile,
122
+ env.ZOTERO_MCP_ENV,
123
+ "~/.config/zotero-local-mcp/env",
124
+ "~/.config/opencode/zotero.env",
125
+ ].filter((x): x is string => Boolean(x));
126
+ const envFile = envCandidates.map(expand).find((p) => existsSync(p));
127
+ const envValues = envFile ? parseEnvFile(envFile) : {};
128
+ const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
129
+ const vault = vaultRaw ? expand(vaultRaw) : undefined;
130
+ const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
131
+ return {
132
+ configFile: file,
133
+ profile: isProfile(user.profile) ? user.profile : "scholar",
134
+ userName: typeof user.userName === "string" && user.userName.trim() ? user.userName.trim() : undefined,
135
+ about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
136
+ language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
137
+ setup: user.setup,
138
+ serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
139
+ envFile,
140
+ vault,
141
+ models: user.models ?? {
142
+ librarian: "opencode-go/glm-5.3-flash",
143
+ researcher: "opencode-go/mimo-v2.6-pro",
144
+ },
145
+ defaultMode: mode,
146
+ vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
147
+ sharedRules: user.sharedRules
148
+ ? expand(user.sharedRules)
149
+ : vault
150
+ ? join(vault, "Systems", "Zotero agent.md")
151
+ : undefined,
152
+ startTimeout: user.startTimeout ?? 45,
153
+ uvPath: user.uvPath ? expand(user.uvPath) : undefined,
154
+ look: user.look,
155
+ quotes: user.quotes,
156
+ themes: user.themes,
157
+ };
158
+ }
159
+
160
+ /** uv is often missing from PATH when pi is started outside a login shell. */
161
+ export function findUv(cfg: Pick<SubsubConfig, "uvPath">, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): string {
162
+ const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
163
+ const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
164
+ const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
165
+ .filter((x): x is string => Boolean(x))
166
+ .map(expand);
167
+ return candidates.find((p) => existsSync(p)) ?? "uv";
168
+ }
169
+
170
+ /**
171
+ * How to start one of the Python programs (zotero-local-mcp, zotero-scholar-mcp,
172
+ * zotero-review): from the local server folder when it exists, otherwise the
173
+ * pinned release from PyPI through `uv tool run`.
174
+ */
175
+ export function serverCommand(
176
+ cfg: Pick<SubsubConfig, "serverDir" | "uvPath">,
177
+ program: string,
178
+ args: string[] = [],
179
+ env: NodeJS.ProcessEnv = process.env,
180
+ ): { command: string; args: string[] } {
181
+ const uv = findUv(cfg, env);
182
+ if (existsSync(join(cfg.serverDir, "pyproject.toml"))) {
183
+ return { command: uv, args: ["run", "--quiet", "--directory", cfg.serverDir, program, ...args] };
184
+ }
185
+ return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
186
+ }
187
+
188
+ /** How prompts name the user. */
189
+ export function who(cfg: Pick<SubsubConfig, "userName">): string {
190
+ return cfg.userName ?? "the user";
191
+ }