@smartdoc/cli 0.0.0-stage → 0.4.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,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # SmartDoc
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Review your documents with your own AI. SmartDoc is a desktop app that shows your document (a PDF); your AI reads it, marks up the text and writes findings, and you decide on each one in the app. The AI then writes the next draft from your decisions.
4
+
5
+ This package is the `smartdoc` command.
6
+
7
+ ## Install the app
8
+
9
+ ```sh
10
+ npx @smartdoc/cli install
11
+ ```
12
+
13
+ Downloads the latest SmartDoc for your Mac from the project's [GitHub releases](https://github.com/tw050x/smartdoc/releases), checks it against the release's checksum, and puts it in Applications (asking before it replaces an older one), then offers to set up your agents. Or download it from the [website](https://tw050x.github.io/smartdoc/). Once installed, SmartDoc keeps itself up to date.
14
+
15
+ ## Set up your agents
16
+
17
+ ```sh
18
+ npx @smartdoc/cli setup
19
+ ```
20
+
21
+ Finds SmartDoc on your Mac and adds it to Claude Desktop and Claude Code, asking before each. (`--yes` doesn't ask.) Claude Desktop's settings file keeps everything else in it, and a copy of the file as it was is saved beside it. Agents run the connection built into the app, so they don't need Node.js once they're set up. You can do the same from the app: click its MCP server status, at the top right.
22
+
23
+ ## Check the connection
24
+
25
+ ```sh
26
+ npx @smartdoc/cli doctor
27
+ ```
28
+
29
+ Checks that SmartDoc is installed and answering, and that Claude Desktop and Claude Code are set up for it where it is now, and says what to fix.
30
+
31
+ ## Run the connection with npx
32
+
33
+ For agents you'd rather set up with `npx`, the command `npx -y @smartdoc/cli mcp` runs SmartDoc's MCP connection. It opens the installed app when your agent first uses it, wherever the app is, and if the app isn't installed it tells your agent to send you to the website. For example:
34
+
35
+ ```sh
36
+ claude mcp add --scope user smartdoc -- npx -y @smartdoc/cli mcp
37
+ ```
38
+
39
+ ## Requirements
40
+
41
+ - macOS (Apple silicon or Intel); Windows is planned.
42
+ - Node.js 18 or later, which runs `npx`.
43
+
44
+ SmartDoc keeps your document and its drafts on your computer. Your agent reads the document through SmartDoc's tools, so what it reads goes wherever your agent sends it.
package/cli.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // The smartdoc command (npx @smartdoc/cli <command>): installs the SmartDoc app, sets up AI apps to
3
+ // use it, checks the connection, and runs SmartDoc's MCP connection for agents set up with npx.
4
+
5
+ const HELP = `Usage: npx @smartdoc/cli <command>
6
+
7
+ Commands:
8
+ install [--yes] Download the latest SmartDoc app into Applications, then offer setup
9
+ setup [--yes] Add SmartDoc to Claude Desktop and Claude Code (asks first; --yes doesn't)
10
+ doctor Check SmartDoc and its connections, and say what to fix
11
+ mcp Run SmartDoc's MCP connection (what agents set up with npx run)
12
+
13
+ SmartDoc itself is a Mac app: install it with the install command, or download it from
14
+ https://tw050x.github.io/smartdoc/.`;
15
+
16
+ const [command, ...rest] = process.argv.slice(2);
17
+
18
+ if (command === 'mcp') {
19
+ await import('./mcp.js');
20
+ } else if (['install', 'setup', 'doctor'].includes(command)) {
21
+ const commands = await import('./lib/commands.js');
22
+ const ask = commands.terminalAsk({ yes: rest.includes('--yes') || rest.includes('-y') });
23
+ try {
24
+ process.exitCode = await commands[command]({ ask });
25
+ } catch (error) {
26
+ console.error(error.message);
27
+ process.exitCode = 1;
28
+ }
29
+ } else {
30
+ console.log(HELP);
31
+ process.exitCode = command && !['help', '--help', '-h'].includes(command) ? 1 : 0;
32
+ }
package/lib/agents.js ADDED
@@ -0,0 +1,98 @@
1
+ import { existsSync } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+
7
+ // Setting up AI apps to reach SmartDoc, shared by the app (its MCP panel, see the app's
8
+ // connect.js) and the smartdoc command (setup and doctor). Each AI app runs `entry`: SmartDoc's
9
+ // MCP launcher (mcp.js), which is inside the app, run with the app's own executable as Node, so
10
+ // the AI app needs nothing else installed.
11
+
12
+ // The entry for an installed app, from its bundle's path.
13
+ export function launcherFor(appBundle) {
14
+ return {
15
+ command: path.join(appBundle, 'Contents', 'MacOS', 'SmartDoc'),
16
+ args: [path.join(appBundle, 'Contents', 'Resources', 'cli', 'mcp.js')],
17
+ env: { ELECTRON_RUN_AS_NODE: '1' },
18
+ };
19
+ }
20
+
21
+ // A word for a shell, quoted only if it needs it.
22
+ const shellWord = (word) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`);
23
+
24
+ // Claude Code's command to add it, for every project (user scope).
25
+ export const claudeCodeCommand = ({ command, args, env }) => [
26
+ 'claude mcp add --scope user smartdoc',
27
+ ...Object.entries(env).map(([k, v]) => `-e ${shellWord(`${k}=${v}`)}`),
28
+ '--',
29
+ ...[command, ...args].map(shellWord),
30
+ ].join(' ');
31
+
32
+ // The JSON config entry that Claude Desktop and most other agents take.
33
+ export const configJson = (entry) => JSON.stringify({ mcpServers: { smartdoc: entry } }, null, 2);
34
+
35
+ // --- Claude Desktop ---------------------------------------------------------------------------
36
+
37
+ // Claude Desktop keeps its MCP servers in this file. Tests and development point SmartDoc at
38
+ // another with SMARTDOC_CLAUDE_CONFIG (which the packaged app ignores), so they never touch the
39
+ // real one.
40
+ export function claudeDesktopConfig({ env = process.env, home = os.homedir(), allowOverride = true } = {}) {
41
+ if (allowOverride && env.SMARTDOC_CLAUDE_CONFIG) return path.resolve(env.SMARTDOC_CLAUDE_CONFIG);
42
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
43
+ }
44
+
45
+ // The config file's contents: {} if there's no file yet; null if it isn't JSON SmartDoc can
46
+ // safely change (then it's left alone).
47
+ async function readConfig(file) {
48
+ let text;
49
+ try {
50
+ text = await fs.readFile(file, 'utf8');
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT') return {};
53
+ return null;
54
+ }
55
+ if (!text.trim()) return {};
56
+ try {
57
+ const config = JSON.parse(text);
58
+ return config && typeof config === 'object' && !Array.isArray(config) ? config : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Whether Claude Desktop also has SmartDoc installed as an extension (the .mcpb from earlier
65
+ // versions), which would show SmartDoc's tools twice.
66
+ async function hasExtension(file) {
67
+ try {
68
+ const names = await fs.readdir(path.join(path.dirname(file), 'Claude Extensions'));
69
+ return names.some((name) => name.toLowerCase().includes('smartdoc'));
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // Where Claude Desktop stands: 'not-installed' (no Claude Desktop folder), 'unreadable' (its config
76
+ // file isn't JSON), 'not-added', 'added', or 'different' (SmartDoc is set up differently, say from
77
+ // before the app moved); and whether the old extension is installed too.
78
+ export async function claudeDesktopStatus(file, entry) {
79
+ if (!existsSync(path.dirname(file))) return { state: 'not-installed' };
80
+ const config = await readConfig(file);
81
+ const extension = await hasExtension(file);
82
+ if (!config) return { state: 'unreadable', file, extension };
83
+ const current = config.mcpServers?.smartdoc;
84
+ if (!current) return { state: 'not-added', extension };
85
+ return { state: isDeepStrictEqual(current, entry) ? 'added' : 'different', extension };
86
+ }
87
+
88
+ // Adds SmartDoc to Claude Desktop's config file, or brings its entry up to date, keeping
89
+ // everything else in the file. The file as it was is copied beside it first.
90
+ export async function addToClaudeDesktop(file, entry) {
91
+ const config = await readConfig(file);
92
+ if (!config) throw new Error(`Claude Desktop’s settings file isn’t in a form SmartDoc can safely change, so it was left alone: ${file}`);
93
+ if (existsSync(file)) await fs.copyFile(file, `${file}.before-smartdoc`);
94
+ const updated = { ...config, mcpServers: { ...(config.mcpServers ?? {}), smartdoc: entry } };
95
+ await fs.mkdir(path.dirname(file), { recursive: true });
96
+ await fs.writeFile(file, `${JSON.stringify(updated, null, 2)}\n`);
97
+ return claudeDesktopStatus(file, entry);
98
+ }
package/lib/apps.js ADDED
@@ -0,0 +1,41 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { APP_ID, APP_NAME } from './platform.js';
7
+
8
+ // Finding the installed SmartDoc app, for the smartdoc command's setup and doctor.
9
+
10
+ const run = promisify(execFile);
11
+
12
+ // Copies that aren't installed: on a disk image, in the Bin, or a download kept by an earlier
13
+ // version's launcher.
14
+ const notInstalled = (p) => p.startsWith('/Volumes/') || p.includes('/.Trash/') || p.includes('/Library/Caches/');
15
+
16
+ // The installed app's bundle: the one in Applications if there is one, else wherever macOS has
17
+ // seen one (Spotlight, by the app's ID). Null if there's none.
18
+ export async function findApp({ home = os.homedir() } = {}) {
19
+ for (const p of [`/Applications/${APP_NAME}.app`, path.join(home, 'Applications', `${APP_NAME}.app`)]) {
20
+ if (existsSync(p)) return p;
21
+ }
22
+ try {
23
+ const { stdout } = await run('mdfind', [`kMDItemCFBundleIdentifier == '${APP_ID}'`]);
24
+ return stdout.split('\n').map((p) => p.trim()).find((p) => p.endsWith('.app') && !notInstalled(p)) ?? null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // The app's version, from its Info.plist; null if it can't be read.
31
+ export async function appVersion(appBundle) {
32
+ try {
33
+ const { stdout } = await run('plutil', ['-extract', 'CFBundleShortVersionString', 'raw', path.join(appBundle, 'Contents', 'Info.plist')]);
34
+ return stdout.trim();
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // Whether the app carries the MCP launcher AI apps run (SmartDoc 0.4.0 and later).
41
+ export const hasLauncher = (appBundle) => existsSync(path.join(appBundle, 'Contents', 'Resources', 'cli', 'mcp.js'));
package/lib/bridge.js ADDED
@@ -0,0 +1,107 @@
1
+ import readline from 'node:readline';
2
+ import { mcpUrl } from './platform.js';
3
+
4
+ // Carries MCP messages between an AI app, which talks to this launcher over stdio (one JSON-RPC
5
+ // message per line), and the app's MCP server over HTTP. The first message the app has to
6
+ // answer opens it, and waits until it's running. If the app goes away (say the user quits it),
7
+ // the next message opens it again.
8
+ //
9
+ // `ensure()` makes sure the app is running; `token()` reads its current token.
10
+ //
11
+ // With `offline` (server.json: what the app's server says about itself, see
12
+ // scripts/server-snapshot.mjs), the bridge answers the AI app's handshake itself, and its requests
13
+ // for the lists of tools and prompts until the app is running, so an AI app isn't kept waiting,
14
+ // and nothing is opened until the AI uses a tool.
15
+ export function runBridge({ input = process.stdin, output = process.stdout, env = process.env, ensure, token, log = () => {}, offline = null, version = '0.0.0' }) {
16
+ let ready = null;
17
+ let running = false;
18
+ const whenReady = () => (ready ??= ensure().then(() => {
19
+ running = true;
20
+ }, (error) => {
21
+ ready = null; // try again with the next message
22
+ throw error;
23
+ }));
24
+
25
+ const write = (message) => output.write(`${JSON.stringify(message)}\n`);
26
+ const fail = (id, message) => id !== undefined && write({ jsonrpc: '2.0', id, error: { code: -32000, message } });
27
+ const answer = (id, result) => write({ jsonrpc: '2.0', id, result });
28
+
29
+ // The reply the bridge gives itself, if it does, to a message the app would otherwise answer.
30
+ function local(message) {
31
+ if (!offline) return undefined;
32
+ const { method, params } = message;
33
+ if (method === 'initialize') {
34
+ const asked = params?.protocolVersion;
35
+ return {
36
+ protocolVersion: offline.protocolVersions.includes(asked) ? asked : offline.protocolVersions[0],
37
+ capabilities: offline.capabilities,
38
+ serverInfo: { name: 'smartdoc', version },
39
+ instructions: offline.instructions,
40
+ };
41
+ }
42
+ if (running) return undefined;
43
+ if (method === 'ping') return {};
44
+ if (method === 'tools/list') return { tools: offline.tools };
45
+ if (method === 'prompts/list') return { prompts: offline.prompts };
46
+ return undefined;
47
+ }
48
+
49
+ async function post(line) {
50
+ const res = await fetch(mcpUrl(env), {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${token() ?? ''}` },
53
+ body: line,
54
+ });
55
+ return { status: res.status, text: (await res.text()).trim() };
56
+ }
57
+
58
+ async function forward(line, message) {
59
+ await whenReady();
60
+ let reply;
61
+ try {
62
+ reply = await post(line);
63
+ } catch {
64
+ // Nothing listening: the app was closed since. Open it again and retry once.
65
+ ready = null;
66
+ running = false;
67
+ await whenReady();
68
+ reply = await post(line);
69
+ }
70
+ if (reply.status >= 400) {
71
+ let reason = `The app refused the request (HTTP ${reply.status}).`;
72
+ try {
73
+ reason = JSON.parse(reply.text).error ?? reason;
74
+ } catch {}
75
+ return fail(message.id, reason);
76
+ }
77
+ if (reply.text) output.write(`${reply.text}\n`);
78
+ }
79
+
80
+ const pending = new Set();
81
+ const lines = readline.createInterface({ input });
82
+ lines.on('line', (line) => {
83
+ if (!line.trim()) return;
84
+ let message;
85
+ try {
86
+ message = JSON.parse(line);
87
+ } catch {
88
+ return;
89
+ }
90
+ // Notifications (no id) mean nothing to the app's server until it's running: it keeps no state
91
+ // between requests.
92
+ if (offline && message.id === undefined && !running) return;
93
+ const reply = local(message);
94
+ if (reply !== undefined) return answer(message.id, reply);
95
+ const job = forward(line, message).catch((error) => {
96
+ log(error.message);
97
+ // A tool call gets the reason as its result, which the AI reads and can pass on (say, that
98
+ // the app isn't installed).
99
+ if (message.method === 'tools/call') answer(message.id, { isError: true, content: [{ type: 'text', text: error.message }] });
100
+ else fail(message.id, error.message);
101
+ });
102
+ pending.add(job);
103
+ job.finally(() => pending.delete(job));
104
+ });
105
+
106
+ return new Promise((resolve) => lines.on('close', () => Promise.allSettled([...pending]).then(resolve)));
107
+ }
@@ -0,0 +1,35 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ // Claude Code, through its own `claude mcp` commands (it keeps its MCP servers itself).
5
+
6
+ const run = promisify(execFile);
7
+
8
+ // Whether the claude command is on this computer.
9
+ export async function hasClaudeCode() {
10
+ try {
11
+ await run('claude', ['--version']);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ // SmartDoc's entry in Claude Code: null if there's none, else what `claude mcp get` says about it.
19
+ export async function claudeCodeEntry() {
20
+ try {
21
+ const { stdout } = await run('claude', ['mcp', 'get', 'smartdoc'], { timeout: 60 * 1000 });
22
+ return stdout;
23
+ } catch {
24
+ return null; // `claude mcp get` fails when there's no such server
25
+ }
26
+ }
27
+
28
+ // Whether that entry runs `entry`.
29
+ export const claudeCodeRuns = (details, entry) => Boolean(details?.includes(entry.command) && details.includes(entry.args[0]));
30
+
31
+ // Adds SmartDoc for every project (user scope), replacing an entry it had.
32
+ export async function addToClaudeCode(entry, { replace = false } = {}) {
33
+ if (replace) await run('claude', ['mcp', 'remove', 'smartdoc']);
34
+ await run('claude', ['mcp', 'add-json', '--scope', 'user', 'smartdoc', JSON.stringify({ type: 'stdio', ...entry })]);
35
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { addToClaudeDesktop, claudeDesktopConfig, claudeDesktopStatus, launcherFor } from './agents.js';
5
+ import { appVersion, findApp, hasLauncher } from './apps.js';
6
+ import { addToClaudeCode, claudeCodeEntry, claudeCodeRuns, hasClaudeCode } from './claude-code.js';
7
+ import { appRunning, installDir, installRelease, latestRelease, quitApp } from './install.js';
8
+ import { probe } from './launch.js';
9
+ import { APP_NAME, WEBSITE } from './platform.js';
10
+
11
+ // The smartdoc command's install, setup and doctor. install puts the latest release of the app
12
+ // in Applications, then offers setup. setup and doctor find the installed app, then look at each
13
+ // AI app SmartDoc can set up itself (Claude Desktop and Claude Code): setup adds SmartDoc to them,
14
+ // asking first; doctor only looks, and says what to fix. `out` is where they write;
15
+ // `ask(question)` resolves true for yes.
16
+
17
+ const ok = (text) => ` ✓ ${text}`;
18
+ const bad = (text) => ` ✗ ${text}`;
19
+ const note = (text) => ` ${text}`;
20
+
21
+ // The installed app and the entry for it, or a reason there's none.
22
+ async function installedApp() {
23
+ const app = await findApp();
24
+ if (!app) return { problem: `${APP_NAME} isn't installed. Install it with: npx @smartdoc/cli install (or download it from ${WEBSITE}), then run this again.` };
25
+ const version = await appVersion(app);
26
+ if (!hasLauncher(app)) return { app, version, problem: `${APP_NAME} ${version ?? ''} in ${app} is too old to connect this way. Update it with: npx @smartdoc/cli install, then run this again.` };
27
+ return { app, version, entry: launcherFor(app) };
28
+ }
29
+
30
+ // Asks on the terminal; with --yes, or with no terminal to ask on, says yes.
31
+ export function terminalAsk({ yes = false } = {}) {
32
+ return async (question) => {
33
+ if (yes || !process.stdin.isTTY) return true;
34
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
35
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
36
+ rl.close();
37
+ return answer === '' || answer === 'y' || answer === 'yes';
38
+ };
39
+ }
40
+
41
+ // The download's progress on one line of the terminal, if there is one.
42
+ function progressLine(stream = process.stdout) {
43
+ if (!stream.isTTY) return { update() {}, done() {} };
44
+ let shown = -1;
45
+ return {
46
+ update(received, total) {
47
+ const percent = total ? Math.floor((received / total) * 100) : null;
48
+ if (percent === shown) return;
49
+ shown = percent;
50
+ const mb = (n) => Math.round(n / 1e6);
51
+ stream.write(`\r ${percent ?? '…'}% ${mb(received)}${total ? ` of ${mb(total)}` : ''} MB `);
52
+ },
53
+ done: () => stream.write('\r\x1b[K'),
54
+ };
55
+ }
56
+
57
+ export async function install({ out = console.log, ask = terminalAsk() } = {}) {
58
+ if (process.platform !== 'darwin') {
59
+ out(`${APP_NAME} runs on macOS for now.`);
60
+ return 1;
61
+ }
62
+ const { version, zip } = await latestRelease();
63
+ if (!zip) {
64
+ out(`The latest release (${version}) has no build for this Mac (${process.arch}).`);
65
+ return 1;
66
+ }
67
+ const dir = await installDir();
68
+ const target = path.join(dir, `${APP_NAME}.app`);
69
+ const current = existsSync(target) ? await appVersion(target) : null;
70
+ if (current === version) out(ok(`${APP_NAME} ${version} is already installed in ${target}`));
71
+ else {
72
+ if (existsSync(target) && !(await ask(`Replace ${APP_NAME} ${current ?? ''} in ${dir} with ${version}?`))) {
73
+ out('Left as it is.');
74
+ return 0;
75
+ }
76
+ if (await appRunning()) {
77
+ if (!(await ask(`${APP_NAME} is open. Quit it, to install ${version}?`))) {
78
+ out(`Quit ${APP_NAME}, then run this again.`);
79
+ return 1;
80
+ }
81
+ await quitApp();
82
+ }
83
+ out(`Downloading ${APP_NAME} ${version} for this Mac…`);
84
+ const line = progressLine();
85
+ try {
86
+ await installRelease(zip, dir, { progress: line.update });
87
+ } finally {
88
+ line.done();
89
+ }
90
+ out(ok(`Installed ${APP_NAME} ${version} in ${target}`));
91
+ }
92
+ if (await ask('\nSet up Claude Desktop and Claude Code to use it now?')) {
93
+ out('');
94
+ return setup({ out, ask });
95
+ }
96
+ out('Set them up later with: npx @smartdoc/cli setup');
97
+ return 0;
98
+ }
99
+
100
+ export async function setup({ out = console.log, ask = terminalAsk() } = {}) {
101
+ const found = await installedApp();
102
+ if (found.problem) {
103
+ out(found.problem);
104
+ return 1;
105
+ }
106
+ out(`Setting up ${APP_NAME} ${found.version} (${found.app}).`);
107
+ let failed = false;
108
+
109
+ out('\nClaude Desktop');
110
+ const file = claudeDesktopConfig();
111
+ const desktop = await claudeDesktopStatus(file, found.entry);
112
+ if (desktop.state === 'not-installed') out(note('Not on this Mac.'));
113
+ else if (desktop.state === 'added') out(ok('Already set up.'));
114
+ else if (desktop.state === 'unreadable') {
115
+ out(bad(`Its settings file isn't in a form SmartDoc can safely change, so it was left alone: ${file}`));
116
+ failed = true;
117
+ } else if (await ask(` ${desktop.state === 'different' ? 'Update SmartDoc in' : 'Add SmartDoc to'} Claude Desktop's settings (a copy of the file is kept)?`)) {
118
+ await addToClaudeDesktop(file, found.entry);
119
+ out(ok('Done. Quit Claude Desktop and open it again to connect.'));
120
+ } else out(note('Skipped.'));
121
+ if (desktop.extension) out(note('SmartDoc is also installed as an extension in Claude Desktop. Remove it in Settings → Extensions, so its tools don’t show twice.'));
122
+
123
+ out('\nClaude Code');
124
+ if (!(await hasClaudeCode())) out(note('Not on this computer (no claude command).'));
125
+ else {
126
+ const details = await claudeCodeEntry();
127
+ if (claudeCodeRuns(details, found.entry)) out(ok('Already set up.'));
128
+ else if (await ask(` ${details ? 'Replace SmartDoc’s setup in' : 'Add SmartDoc to'} Claude Code, for all your projects?`)) {
129
+ try {
130
+ await addToClaudeCode(found.entry, { replace: Boolean(details) });
131
+ out(ok('Done. Start a new Claude Code session to connect.'));
132
+ } catch (error) {
133
+ out(bad(`Claude Code didn't take it: ${error.stderr?.trim() || error.message}`));
134
+ failed = true;
135
+ }
136
+ } else out(note('Skipped.'));
137
+ }
138
+
139
+ out(`\nFor other agents, open ${APP_NAME} and click its MCP server status, at the top right.`);
140
+ return failed ? 1 : 0;
141
+ }
142
+
143
+ export async function doctor({ out = console.log } = {}) {
144
+ let problems = 0;
145
+ const fail = (text, fix) => {
146
+ problems++;
147
+ out(bad(text));
148
+ if (fix) out(note(fix));
149
+ };
150
+
151
+ out(`${APP_NAME}`);
152
+ const found = await installedApp();
153
+ if (found.problem) {
154
+ fail(found.problem);
155
+ return 1;
156
+ }
157
+ out(ok(`${APP_NAME} ${found.version} is installed in ${found.app}`));
158
+ const state = await probe();
159
+ if (state === 'up') out(ok(`${APP_NAME} is running, and its MCP server answers`));
160
+ else if (state === 'locked') fail(`Something answers on SmartDoc's port, but not with SmartDoc's token`, 'Quit SmartDoc and open it again.');
161
+ else out(note(`${APP_NAME} isn't running. That's fine: your agent opens it when it needs it.`));
162
+
163
+ out('\nClaude Desktop');
164
+ const desktop = await claudeDesktopStatus(claudeDesktopConfig(), found.entry);
165
+ if (desktop.state === 'not-installed') out(note('Not on this Mac.'));
166
+ else if (desktop.state === 'added') out(ok('Set up for SmartDoc where it is now'));
167
+ else if (desktop.state === 'not-added') fail('SmartDoc isn’t set up in Claude Desktop', 'Run: npx @smartdoc/cli setup');
168
+ else if (desktop.state === 'different') fail('Claude Desktop is set up for SmartDoc somewhere else (or the old way)', 'Run: npx @smartdoc/cli setup');
169
+ else fail(`Its settings file isn't valid JSON: ${desktop.file}`, 'Fix or remove the file, then run: npx @smartdoc/cli setup');
170
+ if (desktop.extension) fail('SmartDoc is also installed as an extension, so its tools may show twice', 'Remove it in Claude Desktop’s Settings → Extensions.');
171
+
172
+ out('\nClaude Code');
173
+ if (!(await hasClaudeCode())) out(note('Not on this computer (no claude command).'));
174
+ else {
175
+ const details = await claudeCodeEntry();
176
+ if (claudeCodeRuns(details, found.entry)) out(ok('Set up for SmartDoc where it is now'));
177
+ else if (details) fail('Claude Code is set up for SmartDoc somewhere else (or the old way)', 'Run: npx @smartdoc/cli setup');
178
+ else fail('SmartDoc isn’t set up in Claude Code', 'Run: npx @smartdoc/cli setup');
179
+ }
180
+
181
+ out(problems ? `\n${problems} thing${problems === 1 ? '' : 's'} to fix.` : '\nAll good.');
182
+ return problems ? 1 : 0;
183
+ }
package/lib/install.js ADDED
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createWriteStream, existsSync } from 'node:fs';
4
+ import { access, constants, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { Readable, Transform } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { promisify } from 'node:util';
10
+ import { APP_ID, APP_NAME, REPOSITORY } from './platform.js';
11
+
12
+ // Installing the app, for the smartdoc command's install: the latest release's build for this
13
+ // Mac, as the app's own updater finds it (latest-mac.yml lists each zip with its SHA-512), checked
14
+ // and unpacked into Applications. Tests point it at a stand-in release (SMARTDOC_DOWNLOADS) and
15
+ // another folder (SMARTDOC_INSTALL_DIR).
16
+
17
+ const run = promisify(execFile);
18
+
19
+ export const downloadBase = (env = process.env) => (env.SMARTDOC_DOWNLOADS ?? `https://github.com/${REPOSITORY}/releases/latest/download`).replace(/\/$/, '');
20
+
21
+ // The release's version and files, from latest-mac.yml (the few lines electron-builder writes).
22
+ export function parseLatest(text) {
23
+ const files = [];
24
+ let version = null;
25
+ for (const line of text.split(/\r?\n/)) {
26
+ const field = line.match(/^\s*(-\s+)?(\w+):\s*'?([^']*?)'?\s*$/);
27
+ if (!field) continue;
28
+ const [, item, key, value] = field;
29
+ if (!item && !line.startsWith(' ') && key === 'version') version = value;
30
+ else if (item && key === 'url') files.push({ url: value });
31
+ else if (line.startsWith(' ') && files.length && (key === 'sha512' || key === 'size')) files.at(-1)[key] = key === 'size' ? Number(value) : value;
32
+ }
33
+ return { version, files };
34
+ }
35
+
36
+ // The zip for this Mac: arm64 or x64 in its name.
37
+ export function zipFor(files, arch = process.arch) {
38
+ return files.find((f) => f.url.endsWith('.zip') && f.url.includes(`-mac-${arch}`)) ?? null;
39
+ }
40
+
41
+ // Where the app goes: Applications if the user can write there, else ~/Applications.
42
+ export async function installDir({ env = process.env, home = os.homedir() } = {}) {
43
+ if (env.SMARTDOC_INSTALL_DIR) return path.resolve(env.SMARTDOC_INSTALL_DIR);
44
+ try {
45
+ await access('/Applications', constants.W_OK);
46
+ return '/Applications';
47
+ } catch {
48
+ return path.join(home, 'Applications');
49
+ }
50
+ }
51
+
52
+ async function get(url, signal) {
53
+ const res = await fetch(url, { redirect: 'follow', signal });
54
+ if (!res.ok) throw new Error(`Couldn’t download ${url} (HTTP ${res.status}).`);
55
+ return res;
56
+ }
57
+
58
+ // What the latest release offers this Mac: { version, zip } (zip null if there's no build for it).
59
+ export async function latestRelease({ env = process.env, arch = process.arch } = {}) {
60
+ const { version, files } = parseLatest(await (await get(`${downloadBase(env)}/latest-mac.yml`)).text());
61
+ if (!version) throw new Error('The latest release doesn’t say its version.');
62
+ return { version, zip: zipFor(files, arch) };
63
+ }
64
+
65
+ // Downloads and checks the zip, unpacks it, and puts the app in `dir` (replacing one there). Calls
66
+ // `progress(received, total)` as it downloads. Resolves to the app's path.
67
+ export async function installRelease(zip, dir, { env = process.env, progress = () => {} } = {}) {
68
+ const work = await mkdtemp(path.join(os.tmpdir(), 'smartdoc-install-'));
69
+ try {
70
+ const file = path.join(work, path.basename(zip.url));
71
+ const res = await get(`${downloadBase(env)}/${zip.url}`);
72
+ const total = Number(res.headers.get('content-length')) || zip.size || null;
73
+ const hash = createHash('sha512');
74
+ let received = 0;
75
+ const tap = new Transform({
76
+ transform(chunk, _encoding, done) {
77
+ hash.update(chunk);
78
+ received += chunk.length;
79
+ progress(received, total);
80
+ done(null, chunk);
81
+ },
82
+ });
83
+ await pipeline(Readable.fromWeb(res.body), tap, createWriteStream(file));
84
+ if (hash.digest('base64') !== zip.sha512) throw new Error(`The download of ${zip.url} didn’t match the release’s checksum, so it wasn’t used.`);
85
+
86
+ // ditto keeps the app's code signature, permissions and links intact.
87
+ const unpacked = path.join(work, 'unpacked');
88
+ await run('ditto', ['-x', '-k', file, unpacked]);
89
+ const built = path.join(unpacked, `${APP_NAME}.app`);
90
+ if (!existsSync(built)) throw new Error(`${zip.url} doesn’t contain ${APP_NAME}.app.`);
91
+
92
+ await mkdir(dir, { recursive: true });
93
+ const target = path.join(dir, `${APP_NAME}.app`);
94
+ await rm(target, { recursive: true, force: true });
95
+ try {
96
+ await rename(built, target);
97
+ } catch {
98
+ await run('ditto', [built, target]); // another disk: copied instead
99
+ }
100
+ return target;
101
+ } finally {
102
+ await rm(work, { recursive: true, force: true });
103
+ }
104
+ }
105
+
106
+ // Whether the app is open (macOS knows it by its ID), and quitting it.
107
+ export async function appRunning() {
108
+ try {
109
+ const { stdout } = await run('osascript', ['-e', `application id "${APP_ID}" is running`]);
110
+ return stdout.trim() === 'true';
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ export async function quitApp() {
116
+ await run('osascript', ['-e', `tell application id "${APP_ID}" to quit`]);
117
+ for (let i = 0; i < 40 && (await appRunning()); i++) await new Promise((r) => setTimeout(r, 250));
118
+ }