@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 +43 -2
- package/cli.js +32 -0
- package/lib/agents.js +98 -0
- package/lib/apps.js +41 -0
- package/lib/bridge.js +107 -0
- package/lib/claude-code.js +35 -0
- package/lib/commands.js +183 -0
- package/lib/install.js +118 -0
- package/lib/launch.js +96 -0
- package/lib/platform.js +42 -0
- package/lib/server.json +770 -0
- package/mcp.js +20 -0
- package/package.json +29 -4
package/lib/launch.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { APP_ID, APP_NAME, WEBSITE, appEnv, mcpUrl, ownApp, userDataDir } from './platform.js';
|
|
6
|
+
|
|
7
|
+
// Makes sure the app is running with its MCP server up, opening it if not. A running app is
|
|
8
|
+
// shared: every launcher connects to the same one, and it stays open when they disconnect. The
|
|
9
|
+
// app isn't downloaded from here: the user installs it from the website.
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
const START_TIMEOUT_MS = 90 * 1000;
|
|
14
|
+
const POLL_MS = 400;
|
|
15
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
|
|
17
|
+
// The token the app's MCP server expects. It's kept in the app's data folder, so it's only
|
|
18
|
+
// readable by the user (and only exists once the app has run).
|
|
19
|
+
export function readToken({ env = process.env } = {}) {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path.join(userDataDir({ env }), 'mcp-token'), 'utf8').trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 'up' when the server answers with this token; 'down' when nothing is listening; 'locked' when
|
|
28
|
+
// something answers but refuses the token (it may not have been written yet).
|
|
29
|
+
export async function probe({ env = process.env } = {}) {
|
|
30
|
+
const token = readToken({ env });
|
|
31
|
+
try {
|
|
32
|
+
// The server only takes POST, so a GET that gets past the token check answers 405.
|
|
33
|
+
const res = await fetch(mcpUrl(env), { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
34
|
+
await res.body?.cancel();
|
|
35
|
+
return res.status === 405 ? 'up' : 'locked';
|
|
36
|
+
} catch {
|
|
37
|
+
return 'down';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NotInstalled extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super(`${APP_NAME} isn't installed on this computer. Tell the user to download it from ${WEBSITE}, open it once, then try again.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function ensureRunning({ env = process.env, log = () => {}, execPath = process.execPath, platform = process.platform } = {}) {
|
|
48
|
+
if ((await probe({ env })) === 'up') return;
|
|
49
|
+
await openApp({ env, log, execPath, platform });
|
|
50
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if ((await probe({ env })) === 'up') return;
|
|
53
|
+
await sleep(POLL_MS);
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`${APP_NAME} didn’t start in time. Open it yourself, then try again.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Opens the app: the one given by SMARTDOC_APP, else the app this launcher is part of, else the
|
|
59
|
+
// installed app, which macOS finds by its ID wherever it is.
|
|
60
|
+
async function openApp({ env, log, execPath, platform }) {
|
|
61
|
+
const override = env.SMARTDOC_APP;
|
|
62
|
+
if (override) {
|
|
63
|
+
log(`Opening ${override}…`);
|
|
64
|
+
return start(appCommand(path.resolve(override)), appEnv(env));
|
|
65
|
+
}
|
|
66
|
+
if (platform !== 'darwin') throw new Error(`${APP_NAME} runs on macOS for now.`);
|
|
67
|
+
const own = ownApp(execPath);
|
|
68
|
+
log(`Opening ${own ?? APP_NAME}…`);
|
|
69
|
+
try {
|
|
70
|
+
await run('open', own ? [own] : ['-b', APP_ID], { env: appEnv(env) });
|
|
71
|
+
} catch {
|
|
72
|
+
throw new NotInstalled();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// How to start an app given by path: a macOS app bundle, a project folder (run with its own
|
|
77
|
+
// Electron, as in development: the nearest one installed, which in a workspace may be above it)
|
|
78
|
+
// or an executable.
|
|
79
|
+
export function appCommand(target) {
|
|
80
|
+
if (target.endsWith('.app')) return ['open', [target]];
|
|
81
|
+
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
82
|
+
const name = process.platform === 'win32' ? 'electron.cmd' : 'electron';
|
|
83
|
+
for (let dir = target; ; dir = path.dirname(dir)) {
|
|
84
|
+
const electron = path.join(dir, 'node_modules', '.bin', name);
|
|
85
|
+
if (existsSync(electron) || dir === path.dirname(dir)) return [electron, [target]];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [target, []];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Detached, so the app outlives this launcher and the AI app that started it.
|
|
92
|
+
function start([command, args], env) {
|
|
93
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', env });
|
|
94
|
+
child.on('error', () => {}); // reported as a timeout by ensureRunning
|
|
95
|
+
child.unref();
|
|
96
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Where things are on this computer. Everything here can be overridden with environment
|
|
6
|
+
// variables, which development and tests use to keep away from the user's real app and data:
|
|
7
|
+
// SMARTDOC_APP the app to open: a SmartDoc.app, an executable, or a project
|
|
8
|
+
// folder (run with its own Electron), instead of the installed one
|
|
9
|
+
// SMARTDOC_USER_DATA the app's data folder (passed on to a project-folder app)
|
|
10
|
+
// SMARTDOC_PORT the port the app's MCP server listens on (likewise)
|
|
11
|
+
|
|
12
|
+
export const APP_NAME = 'SmartDoc';
|
|
13
|
+
export const APP_ID = 'com.tw050x.smartdoc';
|
|
14
|
+
export const REPOSITORY = 'tw050x/smartdoc';
|
|
15
|
+
export const WEBSITE = 'https://tw050x.github.io/smartdoc/';
|
|
16
|
+
export const DEFAULT_PORT = 47815;
|
|
17
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
|
+
|
|
19
|
+
export const port = (env = process.env) => Number(env.SMARTDOC_PORT) || DEFAULT_PORT;
|
|
20
|
+
export const mcpUrl = (env = process.env) => `http://127.0.0.1:${port(env)}/mcp`;
|
|
21
|
+
|
|
22
|
+
// The folder Electron gives the app for its data (it holds the MCP server's token).
|
|
23
|
+
export function userDataDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
24
|
+
if (env.SMARTDOC_USER_DATA) return path.resolve(env.SMARTDOC_USER_DATA);
|
|
25
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', APP_NAME);
|
|
26
|
+
if (platform === 'win32') return path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), APP_NAME);
|
|
27
|
+
return path.join(env.XDG_CONFIG_HOME ?? path.join(home, '.config'), APP_NAME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The app bundle this launcher runs from, when it's the copy inside the app (run by the app's own
|
|
31
|
+
// executable, …/SmartDoc.app/Contents/MacOS/SmartDoc, as Node); null when it runs from npm.
|
|
32
|
+
export function ownApp(execPath = process.execPath) {
|
|
33
|
+
const match = execPath.match(/^(.*?\.app)\/Contents\/MacOS\/[^/]+$/);
|
|
34
|
+
return match ? match[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The environment for opening the app: without ELECTRON_RUN_AS_NODE, which runs this launcher
|
|
38
|
+
// inside the app as Node and would make an Electron app it starts run as Node too.
|
|
39
|
+
export function appEnv(env = process.env) {
|
|
40
|
+
const { ELECTRON_RUN_AS_NODE, ...rest } = env;
|
|
41
|
+
return rest;
|
|
42
|
+
}
|