@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 +17 -2
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/dist/cli.js +12 -0
- package/dist/config.js +22 -17
- package/dist/init.js +11 -10
- package/dist/shortcut.js +165 -0
- package/dist/subsub.js +40 -0
- package/dist/web.js +702 -0
- package/docs/Subsub.md +13 -0
- package/package.json +5 -3
- package/src/cli.ts +12 -0
- package/src/config.ts +22 -17
- package/src/init.ts +11 -9
- package/src/shortcut.ts +184 -0
- package/src/subsub.ts +38 -0
- package/src/web.ts +710 -0
- package/web/app.css +256 -0
- package/web/app.js +1033 -0
- package/web/fonts/IBMPlexMono.woff2 +0 -0
- package/web/fonts/IBMPlexSans.woff2 +0 -0
- package/web/fonts/IBMPlexSerif.woff2 +0 -0
- package/web/glauca.css +106 -0
- package/web/i18n.js +287 -0
- package/web/icon.svg +1 -0
- package/web/index.html +122 -0
- package/web/vendor/marked.esm.js +78 -0
package/docs/Subsub.md
CHANGED
|
@@ -52,6 +52,19 @@ Templates (type the name, then the argument):
|
|
|
52
52
|
- Librarian: `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
|
|
53
53
|
- Researcher: `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
|
|
54
54
|
|
|
55
|
+
## Web view
|
|
56
|
+
|
|
57
|
+
1. Type `subsub web`. Sub-Sub opens in the browser, with the same modes, profiles, tools and approvals. The approval dialog shows the server preview; the change runs only after Yes.
|
|
58
|
+
2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
|
|
59
|
+
3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
|
|
60
|
+
4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
|
|
61
|
+
5. The labels are in European Portuguese when the language setting is Portuguese. A theme toggle button in the header switches between light, dark and system themes.
|
|
62
|
+
6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
|
|
63
|
+
7. One-click copy buttons appear on every code block and assistant note. The sidebar includes real-time conversation search and an Export button to download the discussion as Markdown.
|
|
64
|
+
8. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
|
|
65
|
+
|
|
66
|
+
The log is in ~/.subsub/web.log.
|
|
67
|
+
|
|
55
68
|
## Profiles
|
|
56
69
|
|
|
57
70
|
The profile sets how much Sub-Sub does. To see the profile, type `/profile`. To change it, type `/profile reader` (or scholar, author, editor). The change is saved.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiagojct/subsub",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sub-Sub: a Zotero librarian and research assistant in the terminal, built on pi",
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Tiago Jacinto",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"README.md",
|
|
47
|
+
"assets",
|
|
47
48
|
"bin",
|
|
48
49
|
"dist",
|
|
49
50
|
"docs",
|
|
@@ -53,7 +54,8 @@
|
|
|
53
54
|
"scripts",
|
|
54
55
|
"src",
|
|
55
56
|
"templates",
|
|
56
|
-
"themes"
|
|
57
|
+
"themes",
|
|
58
|
+
"web"
|
|
57
59
|
],
|
|
58
60
|
"devDependencies": {
|
|
59
61
|
"@types/node": "^24.19.0",
|
package/src/cli.ts
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
|
|
|
17
19
|
import { spawnSync } from "node:child_process";
|
|
@@ -148,6 +150,16 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
|
|
|
148
150
|
process.exitCode = await initMain(args.slice(1), env);
|
|
149
151
|
return;
|
|
150
152
|
}
|
|
153
|
+
if (args[0] === "web" || args[0] === "serve") {
|
|
154
|
+
const { webMain } = await import("./web.ts");
|
|
155
|
+
process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (args[0] === "shortcut") {
|
|
159
|
+
const { shortcutMain } = await import("./shortcut.ts");
|
|
160
|
+
process.exitCode = shortcutMain(args.slice(1), env);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
151
163
|
if (args[0] === "doctor") {
|
|
152
164
|
const { doctorMain } = await import("./doctor.ts");
|
|
153
165
|
process.exitCode = await doctorMain(env);
|
package/src/config.ts
CHANGED
|
@@ -59,8 +59,9 @@ export interface SubsubConfig {
|
|
|
59
59
|
themes?: Partial<Record<Mode, string>> | false;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export function expand(p: string): string {
|
|
63
|
-
|
|
62
|
+
export function expand(p: string, home?: string): string {
|
|
63
|
+
const base = home ?? process.env.HOME ?? homedir();
|
|
64
|
+
return resolve(p.replace(/^~(?=$|\/)/, base));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function parseEnvFile(path: string): Record<string, string> {
|
|
@@ -81,7 +82,7 @@ export function parseEnvFile(path: string): Record<string, string> {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export function configPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
84
|
-
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
|
|
85
|
+
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export function readConfigFile(file: string): Record<string, unknown> {
|
|
@@ -108,6 +109,7 @@ export function isProfile(x: unknown): x is Profile {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
112
|
+
const home = env.HOME;
|
|
111
113
|
const file = configPath(env);
|
|
112
114
|
let user: Partial<SubsubConfig> = {};
|
|
113
115
|
if (existsSync(file)) {
|
|
@@ -117,16 +119,18 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
117
119
|
throw new Error(`Sub-Sub: cannot read ${file}: ${(err as Error).message}`);
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
|
|
123
|
+
const envFile = explicitEnv
|
|
124
|
+
? expand(explicitEnv, home)
|
|
125
|
+
: [
|
|
126
|
+
"~/.config/zotero-local-mcp/env",
|
|
127
|
+
"~/.config/opencode/zotero.env",
|
|
128
|
+
]
|
|
129
|
+
.map((p) => expand(p, home))
|
|
130
|
+
.find((p) => existsSync(p));
|
|
131
|
+
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
128
132
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
129
|
-
const vault = vaultRaw ? expand(vaultRaw) : undefined;
|
|
133
|
+
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
130
134
|
const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
|
|
131
135
|
return {
|
|
132
136
|
configFile: file,
|
|
@@ -135,7 +139,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
135
139
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
136
140
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
|
137
141
|
setup: user.setup,
|
|
138
|
-
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
|
|
142
|
+
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
139
143
|
envFile,
|
|
140
144
|
vault,
|
|
141
145
|
models: user.models ?? {
|
|
@@ -143,14 +147,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
143
147
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
144
148
|
},
|
|
145
149
|
defaultMode: mode,
|
|
146
|
-
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
|
|
150
|
+
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
|
|
147
151
|
sharedRules: user.sharedRules
|
|
148
|
-
? expand(user.sharedRules)
|
|
152
|
+
? expand(user.sharedRules, home)
|
|
149
153
|
: vault
|
|
150
154
|
? join(vault, "Systems", "Zotero agent.md")
|
|
151
155
|
: undefined,
|
|
152
156
|
startTimeout: user.startTimeout ?? 45,
|
|
153
|
-
uvPath: user.uvPath ? expand(user.uvPath) : undefined,
|
|
157
|
+
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
|
154
158
|
look: user.look,
|
|
155
159
|
quotes: user.quotes,
|
|
156
160
|
themes: user.themes,
|
|
@@ -159,11 +163,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
159
163
|
|
|
160
164
|
/** uv is often missing from PATH when pi is started outside a login shell. */
|
|
161
165
|
export function findUv(cfg: Pick<SubsubConfig, "uvPath">, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): string {
|
|
166
|
+
const home = env.HOME;
|
|
162
167
|
const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
|
|
163
168
|
const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
|
|
164
169
|
const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
|
|
165
170
|
.filter((x): x is string => Boolean(x))
|
|
166
|
-
.map(expand);
|
|
171
|
+
.map((p) => expand(p, home));
|
|
167
172
|
return candidates.find((p) => existsSync(p)) ?? "uv";
|
|
168
173
|
}
|
|
169
174
|
|
package/src/init.ts
CHANGED
|
@@ -85,11 +85,12 @@ export async function runInit(
|
|
|
85
85
|
env: NodeJS.ProcessEnv = process.env,
|
|
86
86
|
deps: { fetch?: typeof fetch; home?: string } = {},
|
|
87
87
|
): Promise<InitResult> {
|
|
88
|
-
const home = deps.home ?? homedir();
|
|
89
|
-
const
|
|
88
|
+
const home = deps.home ?? env.HOME ?? homedir();
|
|
89
|
+
const initEnv: NodeJS.ProcessEnv = { ...env, HOME: home };
|
|
90
|
+
const configFile = configPath(initEnv);
|
|
90
91
|
const existing = readConfigFile(configFile) as Record<string, any>;
|
|
91
|
-
const current = loadConfig(
|
|
92
|
-
const envFile = current.envFile ?? expand(
|
|
92
|
+
const current = loadConfig(initEnv);
|
|
93
|
+
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
93
94
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
94
95
|
const created: string[] = [];
|
|
95
96
|
|
|
@@ -109,6 +110,7 @@ export async function runInit(
|
|
|
109
110
|
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";
|
|
110
111
|
const notes = expand(
|
|
111
112
|
(await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(),
|
|
113
|
+
home,
|
|
112
114
|
);
|
|
113
115
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
114
116
|
const profile = await io.choose(
|
|
@@ -117,7 +119,7 @@ export async function runInit(
|
|
|
117
119
|
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
118
120
|
);
|
|
119
121
|
|
|
120
|
-
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
|
|
122
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
|
|
121
123
|
let tags = "keep";
|
|
122
124
|
if (existsSync(vocab)) {
|
|
123
125
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
@@ -136,7 +138,7 @@ export async function runInit(
|
|
|
136
138
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
137
139
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
138
140
|
];
|
|
139
|
-
const agentDir =
|
|
141
|
+
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
140
142
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
141
143
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
142
144
|
|
|
@@ -172,7 +174,7 @@ export async function runInit(
|
|
|
172
174
|
};
|
|
173
175
|
if (models === "later") patch.models = {};
|
|
174
176
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
175
|
-
saveConfig(patch,
|
|
177
|
+
saveConfig(patch, initEnv);
|
|
176
178
|
|
|
177
179
|
const zotero = await zoteroReachable(deps.fetch);
|
|
178
180
|
io.say("");
|
|
@@ -184,8 +186,8 @@ export async function runInit(
|
|
|
184
186
|
? "Zotero: reachable."
|
|
185
187
|
: "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\".",
|
|
186
188
|
);
|
|
187
|
-
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then,
|
|
188
|
-
io.say("Next: type subsub
|
|
189
|
+
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
190
|
+
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.");
|
|
189
191
|
return { configFile, envFile, notes, created, zotero };
|
|
190
192
|
}
|
|
191
193
|
|
package/src/shortcut.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
20
|
+
|
|
21
|
+
export interface ShortcutPlan {
|
|
22
|
+
platform: NodeJS.Platform;
|
|
23
|
+
node: string;
|
|
24
|
+
bin: string;
|
|
25
|
+
home: string;
|
|
26
|
+
packageDir: string;
|
|
27
|
+
desktop: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function planFor(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, desktop = true): ShortcutPlan {
|
|
31
|
+
const home = platform === "win32" ? (env.USERPROFILE ?? homedir()) : (env.HOME ?? homedir());
|
|
32
|
+
return { platform, node: process.execPath, bin: join(PACKAGE_DIR, "bin", "subsub.js"), home, packageDir: PACKAGE_DIR, desktop };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Single-quote a string for sh. */
|
|
36
|
+
export function shQuote(s: string): string {
|
|
37
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Quote an argument for the Exec line of a .desktop file. */
|
|
41
|
+
export function desktopQuote(s: string): string {
|
|
42
|
+
return `"${s.replace(/(["`$\\])/g, "\\$1")}"`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Single-quote a string for PowerShell. */
|
|
46
|
+
export function psQuote(s: string): string {
|
|
47
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function macScript(p: ShortcutPlan): string {
|
|
51
|
+
return [
|
|
52
|
+
"#!/bin/sh",
|
|
53
|
+
"# Opens Sub-Sub in the browser. Written by: subsub shortcut",
|
|
54
|
+
'cd "$HOME" || exit 1',
|
|
55
|
+
`exec ${shQuote(p.node)} ${shQuote(p.bin)} web >/dev/null 2>&1`,
|
|
56
|
+
"",
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function macPlist(version: string): string {
|
|
61
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
62
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
63
|
+
<plist version="1.0">
|
|
64
|
+
<dict>
|
|
65
|
+
<key>CFBundleName</key><string>Sub-Sub</string>
|
|
66
|
+
<key>CFBundleDisplayName</key><string>Sub-Sub</string>
|
|
67
|
+
<key>CFBundleIdentifier</key><string>eu.tiagojacinto.subsub</string>
|
|
68
|
+
<key>CFBundleExecutable</key><string>Sub-Sub</string>
|
|
69
|
+
<key>CFBundleIconFile</key><string>icon</string>
|
|
70
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
71
|
+
<key>CFBundleShortVersionString</key><string>${version}</string>
|
|
72
|
+
<key>CFBundleVersion</key><string>${version}</string>
|
|
73
|
+
<key>LSUIElement</key><true/>
|
|
74
|
+
</dict>
|
|
75
|
+
</plist>
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function desktopEntry(p: ShortcutPlan): string {
|
|
80
|
+
return [
|
|
81
|
+
"[Desktop Entry]",
|
|
82
|
+
"Type=Application",
|
|
83
|
+
"Name=Sub-Sub",
|
|
84
|
+
"Comment=Zotero librarian and research assistant",
|
|
85
|
+
`Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
|
|
86
|
+
`Path=${p.home}`,
|
|
87
|
+
`Icon=${join(p.packageDir, "assets", "icon.png")}`,
|
|
88
|
+
"Terminal=false",
|
|
89
|
+
"Categories=Education;Office;",
|
|
90
|
+
"",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function windowsScript(p: ShortcutPlan): string {
|
|
95
|
+
const lines = [
|
|
96
|
+
"$ErrorActionPreference = 'Stop'",
|
|
97
|
+
"$shell = New-Object -ComObject WScript.Shell",
|
|
98
|
+
"$places = @([Environment]::GetFolderPath('Programs'))",
|
|
99
|
+
...(p.desktop ? ["$places += [Environment]::GetFolderPath('Desktop')"] : []),
|
|
100
|
+
"foreach ($dir in $places) {",
|
|
101
|
+
" $s = $shell.CreateShortcut((Join-Path $dir 'Sub-Sub.lnk'))",
|
|
102
|
+
` $s.TargetPath = ${psQuote(p.node)}`,
|
|
103
|
+
` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
|
|
104
|
+
` $s.WorkingDirectory = ${psQuote(p.home)}`,
|
|
105
|
+
` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
|
|
106
|
+
" $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
|
|
107
|
+
" $s.WindowStyle = 7",
|
|
108
|
+
" $s.Save()",
|
|
109
|
+
" Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
|
|
110
|
+
"}",
|
|
111
|
+
];
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runPowerShell(script: string): { ok: boolean; out: string } {
|
|
116
|
+
const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", windowsHide: true });
|
|
117
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error ? r.error.message : ""}`.trim() };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createShortcuts(p: ShortcutPlan): string[] {
|
|
121
|
+
if (p.platform === "darwin") {
|
|
122
|
+
const app = join(p.home, "Applications", "Sub-Sub.app");
|
|
123
|
+
mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
|
|
124
|
+
mkdirSync(join(app, "Contents", "Resources"), { recursive: true });
|
|
125
|
+
writeFileSync(join(app, "Contents", "Info.plist"), macPlist(packageVersion(p.packageDir)));
|
|
126
|
+
const exe = join(app, "Contents", "MacOS", "Sub-Sub");
|
|
127
|
+
writeFileSync(exe, macScript(p));
|
|
128
|
+
chmodSync(exe, 0o755);
|
|
129
|
+
const icns = join(p.packageDir, "assets", "icon.icns");
|
|
130
|
+
if (existsSync(icns)) copyFileSync(icns, join(app, "Contents", "Resources", "icon.icns"));
|
|
131
|
+
return [app];
|
|
132
|
+
}
|
|
133
|
+
if (p.platform === "win32") {
|
|
134
|
+
const r = runPowerShell(windowsScript(p));
|
|
135
|
+
if (!r.ok) throw new Error(`could not create the shortcut: ${r.out}`);
|
|
136
|
+
return r.out.split(/\r?\n/).filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
const dir = join(p.home, ".local", "share", "applications");
|
|
139
|
+
mkdirSync(dir, { recursive: true });
|
|
140
|
+
const file = join(dir, "sub-sub.desktop");
|
|
141
|
+
writeFileSync(file, desktopEntry(p));
|
|
142
|
+
chmodSync(file, 0o755);
|
|
143
|
+
return [file];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function removeShortcuts(p: ShortcutPlan): string[] {
|
|
147
|
+
if (p.platform === "win32") {
|
|
148
|
+
const script = [
|
|
149
|
+
"foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) {",
|
|
150
|
+
" $f = Join-Path $dir 'Sub-Sub.lnk'",
|
|
151
|
+
" if (Test-Path $f) { Remove-Item $f; Write-Output $f }",
|
|
152
|
+
"}",
|
|
153
|
+
].join("\n");
|
|
154
|
+
const r = runPowerShell(script);
|
|
155
|
+
return r.out.split(/\r?\n/).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
const target = p.platform === "darwin" ? join(p.home, "Applications", "Sub-Sub.app") : join(p.home, ".local", "share", "applications", "sub-sub.desktop");
|
|
158
|
+
if (!existsSync(target)) return [];
|
|
159
|
+
rmSync(target, { recursive: true, force: true });
|
|
160
|
+
return [target];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function shortcutMain(args: string[], env: NodeJS.ProcessEnv = process.env): number {
|
|
164
|
+
const plan = planFor(env, process.platform, !args.includes("--no-desktop"));
|
|
165
|
+
try {
|
|
166
|
+
if (args.includes("--remove")) {
|
|
167
|
+
const gone = removeShortcuts(plan);
|
|
168
|
+
console.log(gone.length ? `Removed:\n${gone.join("\n")}` : "No Sub-Sub shortcut found.");
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
const made = createShortcuts(plan);
|
|
172
|
+
const where =
|
|
173
|
+
plan.platform === "darwin"
|
|
174
|
+
? "Open Sub-Sub from Applications, Launchpad or Spotlight."
|
|
175
|
+
: plan.platform === "win32"
|
|
176
|
+
? "Open Sub-Sub from the Start menu or the desktop."
|
|
177
|
+
: "Open Sub-Sub from your applications menu.";
|
|
178
|
+
console.log(`Shortcut:\n${made.join("\n")}\n${where} It opens Sub-Sub in your browser.`);
|
|
179
|
+
return 0;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.error(`Sub-Sub: ${(err as Error).message}`);
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/subsub.ts
CHANGED
|
@@ -108,6 +108,30 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
108
108
|
const library: LibraryState = { zotero: "checking" };
|
|
109
109
|
const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
|
110
110
|
const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
111
|
+
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
112
|
+
const webView = process.env.SUBSUB_WEB === "1";
|
|
113
|
+
let webCtx: ExtensionContext | undefined;
|
|
114
|
+
function emitWeb(): void {
|
|
115
|
+
const ctx = webCtx;
|
|
116
|
+
if (!webView || !ctx || ctx.mode !== "rpc") return;
|
|
117
|
+
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
118
|
+
const state = {
|
|
119
|
+
mode,
|
|
120
|
+
profile,
|
|
121
|
+
profileLabel: profileSpec(profile).label,
|
|
122
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
123
|
+
down,
|
|
124
|
+
library,
|
|
125
|
+
language: cfg.language,
|
|
126
|
+
userName: cfg.userName ?? null,
|
|
127
|
+
vault: cfg.vault ?? null,
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
ctx.ui.setStatus("subsub.web", JSON.stringify(state));
|
|
131
|
+
} catch {
|
|
132
|
+
/* the context is gone after shutdown */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
111
135
|
|
|
112
136
|
function applyTheme(ctx: ExtensionContext): void {
|
|
113
137
|
if (!lookOn(ctx) || cfg.themes === false) return;
|
|
@@ -145,6 +169,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
145
169
|
library.zotero = "down";
|
|
146
170
|
}
|
|
147
171
|
headerTui?.requestRender();
|
|
172
|
+
emitWeb();
|
|
148
173
|
}
|
|
149
174
|
|
|
150
175
|
function setHeader(ctx: ExtensionContext): void {
|
|
@@ -183,6 +208,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
183
208
|
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
184
209
|
if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
185
210
|
headerTui?.requestRender();
|
|
211
|
+
webCtx = ctx;
|
|
212
|
+
emitWeb();
|
|
186
213
|
}
|
|
187
214
|
|
|
188
215
|
async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
|
|
@@ -218,6 +245,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
218
245
|
if (lookOn(ctx)) {
|
|
219
246
|
setHeader(ctx);
|
|
220
247
|
void refreshLibrary();
|
|
248
|
+
} else if (webView) {
|
|
249
|
+
void refreshLibrary();
|
|
221
250
|
}
|
|
222
251
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
223
252
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
@@ -382,10 +411,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
382
411
|
}
|
|
383
412
|
}
|
|
384
413
|
headerTui?.requestRender();
|
|
414
|
+
emitWeb();
|
|
385
415
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
386
416
|
},
|
|
387
417
|
});
|
|
388
418
|
|
|
419
|
+
pi.registerCommand("subsub-refresh", {
|
|
420
|
+
description: "Sub-Sub: refresh the library counts shown in the web view",
|
|
421
|
+
handler: async (_args, ctx) => {
|
|
422
|
+
webCtx = ctx;
|
|
423
|
+
await refreshLibrary();
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
389
427
|
pi.registerCommand("history", {
|
|
390
428
|
description: "Sub-Sub: recent library changes",
|
|
391
429
|
handler: async (_args, ctx) => {
|