@tiagojct/subsub 0.5.0 → 0.6.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sub-Sub
2
2
 
3
- A Zotero librarian and research assistant that runs in your terminal. It is built on the [pi](https://pi.dev) agent and works with your own Zotero library through Zotero's local API.
3
+ A Zotero librarian and research assistant that runs on your computer, in your browser or in your terminal. It is built on the [pi](https://pi.dev) agent and works with your own Zotero library through Zotero's local API.
4
4
 
5
5
  > "This mere painstaking burrower and grub-worm of a poor devil of a Sub-Sub appears to have gone through the long Vaticans and street-stalls of the earth, picking up whatever random allusions to whales he could anyways find in any book whatsoever." (Moby-Dick, Extracts)
6
6
 
@@ -34,10 +34,13 @@ A profile is your own choice, not a lock.
34
34
 
35
35
  ## Install
36
36
 
37
+ The installers on [subsub.tiagojacinto.eu](https://subsub.tiagojacinto.eu/install/) do all of this, including Node.js and uv. By hand:
38
+
37
39
  1. Type `npm install -g @tiagojct/subsub`. The command is `subsub`.
38
40
  2. Type `subsub init`. Answer the questions. Press Enter to keep a default.
39
41
  3. Type `subsub doctor`. Fix each line marked FIX.
40
- 4. Type `subsub`. Then type `/login` and choose your model provider.
42
+ 4. Type `subsub shortcut` to add a Sub-Sub shortcut (Applications on macOS, the Start menu and the desktop on Windows, the applications menu on Linux).
43
+ 5. Open Sub-Sub with the shortcut, or type `subsub web`. Select the model button and connect a provider with an API key (or, in the terminal, type `subsub`, then `/login`).
41
44
 
42
45
  `subsub init` creates a notes folder with Inbox/ and Systems/, a starter tag list (health sciences, health informatics, or any field) and a file with the note formats (Systems/Zotero agent.md). You can edit both files. It never replaces a file that exists.
43
46
 
@@ -45,6 +48,18 @@ If you already use pi, you can also add Sub-Sub to plain pi: `pi install npm:@ti
45
48
 
46
49
  The Zotero servers (zotero-local-mcp) run from PyPI through uv. To run them from a copy of the repository, add `"serverDir": "/path/to/zotero-local-mcp"` to the settings file.
47
50
 
51
+ ## Web view
52
+
53
+ `subsub web` (or `subsub serve`) opens Sub-Sub in your browser. It has the same modes, profiles, tools and approvals as the terminal:
54
+
55
+ - pi runs in RPC mode with the Sub-Sub extension. Sub-Sub's approval dialog (the server preview) appears in the page, and the change runs only after you select Yes.
56
+ - The server listens only on 127.0.0.1. The address that opens the page carries a random key, which becomes an HttpOnly, SameSite=Strict cookie; other requests need that cookie, the right Host header and a same-origin JSON request. The page has a strict content security policy and shows model output without raw HTML.
57
+ - One server per user: a second `subsub web` opens the running one. Without an open page for 10 minutes (and with nothing waiting for approval), the server stops. `--stay` keeps it running; `--idle N` sets the minutes; `--no-open` does not open a browser; `--librarian` starts in the librarian mode.
58
+ - Labels are in European Portuguese when the language setting is Portuguese, otherwise in English. A theme button in the header toggles light, dark, and system themes.
59
+ - Fast approval shortcuts (`y` to apply, `n` to decline), one-click copy buttons on code blocks and notes, conversation search, and export to Markdown.
60
+ - The model button lists the models you can use and saves an API key for a provider in the same file as `/login`. Sign-ins through a provider's website stay in the terminal (`/login`).
61
+ - A log is kept in `~/.subsub/web.log`.
62
+
48
63
  ## Use
49
64
 
50
65
  - `subsub` starts in the researcher mode. `subsub --librarian` starts in the librarian mode. `/librarian` and `/researcher` change the mode.
Binary file
Binary file
Binary file
package/dist/cli.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * - `subsub review preview|apply NOTE...` runs zotero-review (tag review notes,
13
13
  * several at a time) with Sub-Sub's server folder and settings.
14
14
  * - `subsub init` sets Sub-Sub up; `subsub doctor` checks the setup.
15
+ * - `subsub web` (or `subsub serve`) opens Sub-Sub in the browser;
16
+ * `subsub shortcut` adds a Sub-Sub shortcut that does the same.
15
17
  */
16
18
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
17
19
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
@@ -156,6 +158,16 @@ export async function run(argv = process.argv.slice(2)) {
156
158
  process.exitCode = await initMain(args.slice(1), env);
157
159
  return;
158
160
  }
161
+ if (args[0] === "web" || args[0] === "serve") {
162
+ const { webMain } = await import("./web.js");
163
+ process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
164
+ return;
165
+ }
166
+ if (args[0] === "shortcut") {
167
+ const { shortcutMain } = await import("./shortcut.js");
168
+ process.exitCode = shortcutMain(args.slice(1), env);
169
+ return;
170
+ }
159
171
  if (args[0] === "doctor") {
160
172
  const { doctorMain } = await import("./doctor.js");
161
173
  process.exitCode = await doctorMain(env);
package/dist/config.js CHANGED
@@ -19,8 +19,9 @@ import { dirname, join, resolve } from "node:path";
19
19
  export const PROFILES = ["reader", "scholar", "author", "editor"];
20
20
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
21
21
  export const SERVER_VERSION = "0.4.1";
22
- export function expand(p) {
23
- return resolve(p.replace(/^~(?=$|\/)/, homedir()));
22
+ export function expand(p, home) {
23
+ const base = home ?? process.env.HOME ?? homedir();
24
+ return resolve(p.replace(/^~(?=$|\/)/, base));
24
25
  }
25
26
  export function parseEnvFile(path) {
26
27
  const out = {};
@@ -41,7 +42,7 @@ export function parseEnvFile(path) {
41
42
  return out;
42
43
  }
43
44
  export function configPath(env = process.env) {
44
- return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
45
+ return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
45
46
  }
46
47
  export function readConfigFile(file) {
47
48
  if (!existsSync(file))
@@ -68,6 +69,7 @@ export function isProfile(x) {
68
69
  return typeof x === "string" && PROFILES.includes(x);
69
70
  }
70
71
  export function loadConfig(env = process.env) {
72
+ const home = env.HOME;
71
73
  const file = configPath(env);
72
74
  let user = {};
73
75
  if (existsSync(file)) {
@@ -78,16 +80,18 @@ export function loadConfig(env = process.env) {
78
80
  throw new Error(`Sub-Sub: cannot read ${file}: ${err.message}`);
79
81
  }
80
82
  }
81
- const envCandidates = [
82
- user.envFile,
83
- env.ZOTERO_MCP_ENV,
84
- "~/.config/zotero-local-mcp/env",
85
- "~/.config/opencode/zotero.env",
86
- ].filter((x) => Boolean(x));
87
- const envFile = envCandidates.map(expand).find((p) => existsSync(p));
88
- const envValues = envFile ? parseEnvFile(envFile) : {};
83
+ const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
84
+ const envFile = explicitEnv
85
+ ? expand(explicitEnv, home)
86
+ : [
87
+ "~/.config/zotero-local-mcp/env",
88
+ "~/.config/opencode/zotero.env",
89
+ ]
90
+ .map((p) => expand(p, home))
91
+ .find((p) => existsSync(p));
92
+ const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
89
93
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
90
- const vault = vaultRaw ? expand(vaultRaw) : undefined;
94
+ const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
91
95
  const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
92
96
  return {
93
97
  configFile: file,
@@ -96,7 +100,7 @@ export function loadConfig(env = process.env) {
96
100
  about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
97
101
  language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
98
102
  setup: user.setup,
99
- serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
103
+ serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
100
104
  envFile,
101
105
  vault,
102
106
  models: user.models ?? {
@@ -104,14 +108,14 @@ export function loadConfig(env = process.env) {
104
108
  researcher: "opencode-go/mimo-v2.6-pro",
105
109
  },
106
110
  defaultMode: mode,
107
- vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
111
+ vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
108
112
  sharedRules: user.sharedRules
109
- ? expand(user.sharedRules)
113
+ ? expand(user.sharedRules, home)
110
114
  : vault
111
115
  ? join(vault, "Systems", "Zotero agent.md")
112
116
  : undefined,
113
117
  startTimeout: user.startTimeout ?? 45,
114
- uvPath: user.uvPath ? expand(user.uvPath) : undefined,
118
+ uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
115
119
  look: user.look,
116
120
  quotes: user.quotes,
117
121
  themes: user.themes,
@@ -119,11 +123,12 @@ export function loadConfig(env = process.env) {
119
123
  }
120
124
  /** uv is often missing from PATH when pi is started outside a login shell. */
121
125
  export function findUv(cfg, env = process.env, platform = process.platform) {
126
+ const home = env.HOME;
122
127
  const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
123
128
  const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
124
129
  const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
125
130
  .filter((x) => Boolean(x))
126
- .map(expand);
131
+ .map((p) => expand(p, home));
127
132
  return candidates.find((p) => existsSync(p)) ?? "uv";
128
133
  }
129
134
  /**
package/dist/init.js CHANGED
@@ -63,11 +63,12 @@ export async function zoteroReachable(fetchFn = fetch) {
63
63
  }
64
64
  }
65
65
  export async function runInit(io, flags, env = process.env, deps = {}) {
66
- const home = deps.home ?? homedir();
67
- const configFile = configPath(env);
66
+ const home = deps.home ?? env.HOME ?? homedir();
67
+ const initEnv = { ...env, HOME: home };
68
+ const configFile = configPath(initEnv);
68
69
  const existing = readConfigFile(configFile);
69
- const current = loadConfig(env);
70
- const envFile = current.envFile ?? expand(env.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"));
70
+ const current = loadConfig(initEnv);
71
+ const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
71
72
  const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
72
73
  const created = [];
73
74
  io.say(existsSync(configFile) ? `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.` : "Sub-Sub setup. Press Enter to take the value in brackets.");
@@ -79,10 +80,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
79
80
  const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
80
81
  const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
81
82
  const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
82
- const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim());
83
+ const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(), home);
83
84
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
84
85
  const profile = await io.choose("Profile (change it later with /profile)", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
85
- const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
86
+ const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
86
87
  let tags = "keep";
87
88
  if (existsSync(vocab)) {
88
89
  io.say(`Tag list: keeping ${vocab}.`);
@@ -96,7 +97,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
96
97
  { value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
97
98
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
98
99
  ];
99
- const agentDir = env.SUBSUB_AGENT_DIR ? expand(env.SUBSUB_AGENT_DIR) : join(home, ".subsub", "agent");
100
+ const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
100
101
  const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
101
102
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
102
103
  // ---- write
@@ -132,7 +133,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
132
133
  patch.models = {};
133
134
  if (models === "opencode-go")
134
135
  patch.models = TESTED_MODELS;
135
- saveConfig(patch, env);
136
+ saveConfig(patch, initEnv);
136
137
  const zotero = await zoteroReachable(deps.fetch);
137
138
  io.say("");
138
139
  io.say(`Saved: ${configFile}`);
@@ -143,8 +144,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
143
144
  ? "Zotero: reachable."
144
145
  : "Zotero: not reachable. Start Zotero 10, then in Zotero open Settings > Advanced and turn on \"Allow other applications on this computer to communicate with Zotero\".");
145
146
  if (fmup)
146
- io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, choose a model provider with /login.");
147
- io.say("Next: type subsub. In Sub-Sub, type /login to connect a model provider. To check the setup, type subsub doctor.");
147
+ io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
148
+ io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
148
149
  return { configFile, envFile, notes, created, zotero };
149
150
  }
150
151
  export function parseFlags(args) {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `subsub shortcut`: a Sub-Sub shortcut that opens the web view, so students
3
+ * can start Sub-Sub without a terminal.
4
+ *
5
+ * - macOS: ~/Applications/Sub-Sub.app (a small app bundle that runs `subsub web`;
6
+ * no Dock icon, it stops by itself when the page has been closed for a while).
7
+ * - Windows: Sub-Sub in the Start menu and on the desktop.
8
+ * - Linux: ~/.local/share/applications/sub-sub.desktop.
9
+ *
10
+ * The shortcut runs this copy of Sub-Sub with this Node.js (absolute paths), so
11
+ * it works without PATH. Run `subsub shortcut` again after moving either.
12
+ * `subsub shortcut --remove` removes what this command created.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { PACKAGE_DIR, packageVersion } from "./cli.js";
19
+ export function planFor(env = process.env, platform = process.platform, desktop = true) {
20
+ const home = platform === "win32" ? (env.USERPROFILE ?? homedir()) : (env.HOME ?? homedir());
21
+ return { platform, node: process.execPath, bin: join(PACKAGE_DIR, "bin", "subsub.js"), home, packageDir: PACKAGE_DIR, desktop };
22
+ }
23
+ /** Single-quote a string for sh. */
24
+ export function shQuote(s) {
25
+ return `'${s.replace(/'/g, `'\\''`)}'`;
26
+ }
27
+ /** Quote an argument for the Exec line of a .desktop file. */
28
+ export function desktopQuote(s) {
29
+ return `"${s.replace(/(["`$\\])/g, "\\$1")}"`;
30
+ }
31
+ /** Single-quote a string for PowerShell. */
32
+ export function psQuote(s) {
33
+ return `'${s.replace(/'/g, "''")}'`;
34
+ }
35
+ export function macScript(p) {
36
+ return [
37
+ "#!/bin/sh",
38
+ "# Opens Sub-Sub in the browser. Written by: subsub shortcut",
39
+ 'cd "$HOME" || exit 1',
40
+ `exec ${shQuote(p.node)} ${shQuote(p.bin)} web >/dev/null 2>&1`,
41
+ "",
42
+ ].join("\n");
43
+ }
44
+ export function macPlist(version) {
45
+ return `<?xml version="1.0" encoding="UTF-8"?>
46
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
47
+ <plist version="1.0">
48
+ <dict>
49
+ <key>CFBundleName</key><string>Sub-Sub</string>
50
+ <key>CFBundleDisplayName</key><string>Sub-Sub</string>
51
+ <key>CFBundleIdentifier</key><string>eu.tiagojacinto.subsub</string>
52
+ <key>CFBundleExecutable</key><string>Sub-Sub</string>
53
+ <key>CFBundleIconFile</key><string>icon</string>
54
+ <key>CFBundlePackageType</key><string>APPL</string>
55
+ <key>CFBundleShortVersionString</key><string>${version}</string>
56
+ <key>CFBundleVersion</key><string>${version}</string>
57
+ <key>LSUIElement</key><true/>
58
+ </dict>
59
+ </plist>
60
+ `;
61
+ }
62
+ export function desktopEntry(p) {
63
+ return [
64
+ "[Desktop Entry]",
65
+ "Type=Application",
66
+ "Name=Sub-Sub",
67
+ "Comment=Zotero librarian and research assistant",
68
+ `Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
69
+ `Path=${p.home}`,
70
+ `Icon=${join(p.packageDir, "assets", "icon.png")}`,
71
+ "Terminal=false",
72
+ "Categories=Education;Office;",
73
+ "",
74
+ ].join("\n");
75
+ }
76
+ export function windowsScript(p) {
77
+ const lines = [
78
+ "$ErrorActionPreference = 'Stop'",
79
+ "$shell = New-Object -ComObject WScript.Shell",
80
+ "$places = @([Environment]::GetFolderPath('Programs'))",
81
+ ...(p.desktop ? ["$places += [Environment]::GetFolderPath('Desktop')"] : []),
82
+ "foreach ($dir in $places) {",
83
+ " $s = $shell.CreateShortcut((Join-Path $dir 'Sub-Sub.lnk'))",
84
+ ` $s.TargetPath = ${psQuote(p.node)}`,
85
+ ` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
86
+ ` $s.WorkingDirectory = ${psQuote(p.home)}`,
87
+ ` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
88
+ " $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
89
+ " $s.WindowStyle = 7",
90
+ " $s.Save()",
91
+ " Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
92
+ "}",
93
+ ];
94
+ return lines.join("\n");
95
+ }
96
+ function runPowerShell(script) {
97
+ const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", windowsHide: true });
98
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error ? r.error.message : ""}`.trim() };
99
+ }
100
+ export function createShortcuts(p) {
101
+ if (p.platform === "darwin") {
102
+ const app = join(p.home, "Applications", "Sub-Sub.app");
103
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
104
+ mkdirSync(join(app, "Contents", "Resources"), { recursive: true });
105
+ writeFileSync(join(app, "Contents", "Info.plist"), macPlist(packageVersion(p.packageDir)));
106
+ const exe = join(app, "Contents", "MacOS", "Sub-Sub");
107
+ writeFileSync(exe, macScript(p));
108
+ chmodSync(exe, 0o755);
109
+ const icns = join(p.packageDir, "assets", "icon.icns");
110
+ if (existsSync(icns))
111
+ copyFileSync(icns, join(app, "Contents", "Resources", "icon.icns"));
112
+ return [app];
113
+ }
114
+ if (p.platform === "win32") {
115
+ const r = runPowerShell(windowsScript(p));
116
+ if (!r.ok)
117
+ throw new Error(`could not create the shortcut: ${r.out}`);
118
+ return r.out.split(/\r?\n/).filter(Boolean);
119
+ }
120
+ const dir = join(p.home, ".local", "share", "applications");
121
+ mkdirSync(dir, { recursive: true });
122
+ const file = join(dir, "sub-sub.desktop");
123
+ writeFileSync(file, desktopEntry(p));
124
+ chmodSync(file, 0o755);
125
+ return [file];
126
+ }
127
+ export function removeShortcuts(p) {
128
+ if (p.platform === "win32") {
129
+ const script = [
130
+ "foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) {",
131
+ " $f = Join-Path $dir 'Sub-Sub.lnk'",
132
+ " if (Test-Path $f) { Remove-Item $f; Write-Output $f }",
133
+ "}",
134
+ ].join("\n");
135
+ const r = runPowerShell(script);
136
+ return r.out.split(/\r?\n/).filter(Boolean);
137
+ }
138
+ const target = p.platform === "darwin" ? join(p.home, "Applications", "Sub-Sub.app") : join(p.home, ".local", "share", "applications", "sub-sub.desktop");
139
+ if (!existsSync(target))
140
+ return [];
141
+ rmSync(target, { recursive: true, force: true });
142
+ return [target];
143
+ }
144
+ export function shortcutMain(args, env = process.env) {
145
+ const plan = planFor(env, process.platform, !args.includes("--no-desktop"));
146
+ try {
147
+ if (args.includes("--remove")) {
148
+ const gone = removeShortcuts(plan);
149
+ console.log(gone.length ? `Removed:\n${gone.join("\n")}` : "No Sub-Sub shortcut found.");
150
+ return 0;
151
+ }
152
+ const made = createShortcuts(plan);
153
+ const where = plan.platform === "darwin"
154
+ ? "Open Sub-Sub from Applications, Launchpad or Spotlight."
155
+ : plan.platform === "win32"
156
+ ? "Open Sub-Sub from the Start menu or the desktop."
157
+ : "Open Sub-Sub from your applications menu.";
158
+ console.log(`Shortcut:\n${made.join("\n")}\n${where} It opens Sub-Sub in your browser.`);
159
+ return 0;
160
+ }
161
+ catch (err) {
162
+ console.error(`Sub-Sub: ${err.message}`);
163
+ return 1;
164
+ }
165
+ }
package/dist/subsub.js CHANGED
@@ -93,6 +93,32 @@ export async function createSubsub(pi, deps = {}) {
93
93
  const library = { zotero: "checking" };
94
94
  const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
95
95
  const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
96
+ /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
97
+ const webView = process.env.SUBSUB_WEB === "1";
98
+ let webCtx;
99
+ function emitWeb() {
100
+ const ctx = webCtx;
101
+ if (!webView || !ctx || ctx.mode !== "rpc")
102
+ return;
103
+ const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
104
+ const state = {
105
+ mode,
106
+ profile,
107
+ profileLabel: profileSpec(profile).label,
108
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
109
+ down,
110
+ library,
111
+ language: cfg.language,
112
+ userName: cfg.userName ?? null,
113
+ vault: cfg.vault ?? null,
114
+ };
115
+ try {
116
+ ctx.ui.setStatus("subsub.web", JSON.stringify(state));
117
+ }
118
+ catch {
119
+ /* the context is gone after shutdown */
120
+ }
121
+ }
96
122
  function applyTheme(ctx) {
97
123
  if (!lookOn(ctx) || cfg.themes === false)
98
124
  return;
@@ -125,6 +151,7 @@ export async function createSubsub(pi, deps = {}) {
125
151
  library.zotero = "down";
126
152
  }
127
153
  headerTui?.requestRender();
154
+ emitWeb();
128
155
  }
129
156
  function setHeader(ctx) {
130
157
  if (!lookOn(ctx))
@@ -163,6 +190,8 @@ export async function createSubsub(pi, deps = {}) {
163
190
  if (standalone)
164
191
  ctx.ui.setTitle(`Sub-Sub: ${mode}`);
165
192
  headerTui?.requestRender();
193
+ webCtx = ctx;
194
+ emitWeb();
166
195
  }
167
196
  async function applyMode(ctx, next, remember) {
168
197
  mode = next;
@@ -201,6 +230,9 @@ export async function createSubsub(pi, deps = {}) {
201
230
  setHeader(ctx);
202
231
  void refreshLibrary();
203
232
  }
233
+ else if (webView) {
234
+ void refreshLibrary();
235
+ }
204
236
  for (const [name, err] of Object.entries(bridge.errors)) {
205
237
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
206
238
  }
@@ -362,9 +394,17 @@ export async function createSubsub(pi, deps = {}) {
362
394
  }
363
395
  }
364
396
  headerTui?.requestRender();
397
+ emitWeb();
365
398
  ctx.ui.notify(lines.join("\n"), "info");
366
399
  },
367
400
  });
401
+ pi.registerCommand("subsub-refresh", {
402
+ description: "Sub-Sub: refresh the library counts shown in the web view",
403
+ handler: async (_args, ctx) => {
404
+ webCtx = ctx;
405
+ await refreshLibrary();
406
+ },
407
+ });
368
408
  pi.registerCommand("history", {
369
409
  description: "Sub-Sub: recent library changes",
370
410
  handler: async (_args, ctx) => {