@tiagojct/subsub 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,17 @@ 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.
59
+ - 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`).
60
+ - A log is kept in `~/.subsub/web.log`.
61
+
48
62
  ## Use
49
63
 
50
64
  - `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/init.js CHANGED
@@ -143,8 +143,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
143
143
  ? "Zotero: reachable."
144
144
  : "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
145
  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.");
146
+ io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
147
+ 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
148
  return { configFile, envFile, notes, created, zotero };
149
149
  }
150
150
  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) => {