@smartspec/cli 0.0.0-stage → 0.0.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,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # SmartSpec
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
+ Plan systems and define the contracts between them, with your own AI. SmartSpec is a desktop app that your AI connects to over MCP.
4
+
5
+ This package is the `smartspec` command.
6
+
7
+ SmartSpec must be installed in your Applications folder: that's where `setup` and your agents expect it. If you download it from the [releases](https://github.com/tw050x/smartspec/releases/latest), drag it into Applications.
8
+
9
+ ## Install the app
10
+
11
+ ```sh
12
+ npx @smartspec/cli install
13
+ ```
14
+
15
+ Downloads the latest SmartSpec for your Mac from the project's [GitHub releases](https://github.com/tw050x/smartspec/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. Once installed, SmartSpec keeps itself up to date.
16
+
17
+ ## Set up your agents
18
+
19
+ ```sh
20
+ npx @smartspec/cli setup
21
+ ```
22
+
23
+ Finds SmartSpec in your Applications folder 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.
24
+
25
+ ## Wait for the user
26
+
27
+ ```sh
28
+ npx @smartspec/cli wait proposal --id <id>
29
+ ```
30
+
31
+ Stays until the user has accepted or discarded a proposal, then prints what happened and exits. AI apps that can run commands in the background use it to carry on as soon as the user is done; SmartSpec's tools give them the exact command. `wait --help` lists its exit codes.
32
+
33
+ ## Check the connection
34
+
35
+ ```sh
36
+ npx @smartspec/cli doctor
37
+ ```
38
+
39
+ Checks that SmartSpec 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.
40
+
41
+ ## Run the connection with npx
42
+
43
+ For agents you'd rather set up with `npx`, the command `npx -y @smartspec/cli mcp` runs SmartSpec'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 where to send you. For example:
44
+
45
+ ```sh
46
+ claude mcp add --scope user smartspec -- npx -y @smartspec/cli mcp
47
+ ```
48
+
49
+ ## Requirements
50
+
51
+ - macOS (Apple silicon or Intel); Windows is planned.
52
+ - Node.js 18 or later, which runs `npx`.
package/cli.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // The smartspec command (npx @smartspec/cli <command>): installs the SmartSpec app, sets up AI apps to
3
+ // use it, checks the connection, and runs SmartSpec's MCP connection for agents set up with npx.
4
+
5
+ const HELP = `Usage: npx @smartspec/cli <command>
6
+
7
+ Commands:
8
+ install [--yes] Download the latest SmartSpec app into Applications, then offer setup
9
+ setup [--yes] Add SmartSpec to Claude Desktop and Claude Code (asks first; --yes doesn't)
10
+ doctor Check SmartSpec and its connections, and say what to fix
11
+ mcp Run SmartSpec's MCP connection (what agents set up with npx run)
12
+ wait Stay until the user has done what an AI is waiting for (wait --help)
13
+
14
+ SmartSpec itself is a Mac app: install it with the install command, or download it from
15
+ https://github.com/tw050x/smartspec/releases/latest.`;
16
+
17
+ const [command, ...rest] = process.argv.slice(2);
18
+
19
+ if (command === 'mcp') {
20
+ await import('./mcp.js');
21
+ } else if (command === 'wait') {
22
+ const { WAIT_HELP, wait } = await import('./lib/wait.js');
23
+ if (rest.includes('--help') || rest.includes('-h')) console.log(WAIT_HELP);
24
+ else process.exitCode = await wait(rest);
25
+ } else if (['install', 'setup', 'doctor'].includes(command)) {
26
+ const commands = await import('./lib/commands.js');
27
+ const ask = commands.terminalAsk({ yes: rest.includes('--yes') || rest.includes('-y') });
28
+ try {
29
+ process.exitCode = await commands[command]({ ask });
30
+ } catch (error) {
31
+ console.error(error.message);
32
+ process.exitCode = 1;
33
+ }
34
+ } else {
35
+ console.log(HELP);
36
+ process.exitCode = command && !['help', '--help', '-h'].includes(command) ? 1 : 0;
37
+ }
package/lib/agents.js ADDED
@@ -0,0 +1,89 @@
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 SmartSpec, shared by the app (its MCP panel, see the app's
8
+ // connect.js) and the smartspec command (setup and doctor). Each AI app runs `entry`: SmartSpec'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', 'SmartSpec'),
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
+ // Each AI app knows SmartSpec by `name`: smartspec, or smartspec-dev for a development copy, so the
25
+ // two can be set up side by side.
26
+
27
+ // Claude Code's command to add it, for every project (user scope).
28
+ export const claudeCodeCommand = ({ command, args, env }, name = 'smartspec') => [
29
+ `claude mcp add --scope user ${name}`,
30
+ ...Object.entries(env).map(([k, v]) => `-e ${shellWord(`${k}=${v}`)}`),
31
+ '--',
32
+ ...[command, ...args].map(shellWord),
33
+ ].join(' ');
34
+
35
+ // The JSON config entry that Claude Desktop and most other agents take.
36
+ export const configJson = (entry, name = 'smartspec') => JSON.stringify({ mcpServers: { [name]: entry } }, null, 2);
37
+
38
+ // --- Claude Desktop ---------------------------------------------------------------------------
39
+
40
+ // Claude Desktop keeps its MCP servers in this file. Tests and development point SmartSpec at
41
+ // another with SMARTSPEC_CLAUDE_CONFIG (which the packaged app ignores), so they never touch the
42
+ // real one.
43
+ export function claudeDesktopConfig({ env = process.env, home = os.homedir(), allowOverride = true } = {}) {
44
+ if (allowOverride && env.SMARTSPEC_CLAUDE_CONFIG) return path.resolve(env.SMARTSPEC_CLAUDE_CONFIG);
45
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
46
+ }
47
+
48
+ // The config file's contents: {} if there's no file yet; null if it isn't JSON SmartSpec can
49
+ // safely change (then it's left alone).
50
+ async function readConfig(file) {
51
+ let text;
52
+ try {
53
+ text = await fs.readFile(file, 'utf8');
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT') return {};
56
+ return null;
57
+ }
58
+ if (!text.trim()) return {};
59
+ try {
60
+ const config = JSON.parse(text);
61
+ return config && typeof config === 'object' && !Array.isArray(config) ? config : null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // Where Claude Desktop stands: 'not-installed' (no Claude Desktop folder), 'unreadable' (its config
68
+ // file isn't JSON), 'not-added', 'added', or 'different' (SmartSpec is set up differently, say from
69
+ // before the app moved).
70
+ export async function claudeDesktopStatus(file, entry, name = 'smartspec') {
71
+ if (!existsSync(path.dirname(file))) return { state: 'not-installed' };
72
+ const config = await readConfig(file);
73
+ if (!config) return { state: 'unreadable', file };
74
+ const current = config.mcpServers?.[name];
75
+ if (!current) return { state: 'not-added' };
76
+ return { state: isDeepStrictEqual(current, entry) ? 'added' : 'different' };
77
+ }
78
+
79
+ // Adds SmartSpec to Claude Desktop's config file, or brings its entry up to date, keeping
80
+ // everything else in the file. The file as it was is copied beside it first.
81
+ export async function addToClaudeDesktop(file, entry, name = 'smartspec') {
82
+ const config = await readConfig(file);
83
+ if (!config) throw new Error(`Claude Desktop’s settings file isn’t in a form SmartSpec can safely change, so it was left alone: ${file}`);
84
+ if (existsSync(file)) await fs.copyFile(file, `${file}.before-smartspec`);
85
+ const updated = { ...config, mcpServers: { ...(config.mcpServers ?? {}), [name]: entry } };
86
+ await fs.mkdir(path.dirname(file), { recursive: true });
87
+ await fs.writeFile(file, `${JSON.stringify(updated, null, 2)}\n`);
88
+ return claudeDesktopStatus(file, entry, name);
89
+ }
package/lib/apps.js ADDED
@@ -0,0 +1,38 @@
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 SmartSpec app, for the smartspec 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
+ }
package/lib/bridge.js ADDED
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import readline from 'node:readline';
3
+ import { mcpUrl } from './platform.js';
4
+
5
+ // Carries MCP messages between an AI app, which talks to this launcher over stdio (one JSON-RPC
6
+ // message per line), and the app's MCP server over HTTP. The first message the app has to
7
+ // answer opens it, and waits until it's running. If the app goes away (say the user quits it),
8
+ // the next message opens it again.
9
+ //
10
+ // `ensure()` makes sure the app is running; `token()` reads its current token.
11
+ //
12
+ // With `offline` (server.json: what the app's server says about itself, see
13
+ // scripts/server-snapshot.mjs), the bridge answers the AI app's handshake itself, and its requests
14
+ // for the lists of tools and prompts until the app is running, so an AI app isn't kept waiting,
15
+ // and nothing is opened until the AI uses a tool.
16
+ export function runBridge({ input = process.stdin, output = process.stdout, env = process.env, ensure, token, log = () => {}, offline = null, version = '0.0.0', heartbeatMs = 20000 }) {
17
+ let ready = null;
18
+ let running = false;
19
+ // This connection's session, which keeps its own window in the app; the AI app's name for itself,
20
+ // to show there; and whether this launcher started the app, so the session has the window it opened.
21
+ const session = randomUUID();
22
+ let client = null;
23
+ let startedApp = false;
24
+ let heartbeat = null;
25
+ const whenReady = () => (ready ??= ensure().then((started) => {
26
+ running = true;
27
+ if (started) startedApp = true;
28
+ heartbeat ??= setInterval(() => notify('alive').catch(() => {}), heartbeatMs);
29
+ heartbeat.unref?.();
30
+ }, (error) => {
31
+ ready = null; // try again with the next message
32
+ throw error;
33
+ }));
34
+
35
+ const write = (message) => output.write(`${JSON.stringify(message)}\n`);
36
+ const fail = (id, message) => id !== undefined && write({ jsonrpc: '2.0', id, error: { code: -32000, message } });
37
+ const answer = (id, result) => write({ jsonrpc: '2.0', id, result });
38
+
39
+ // The reply the bridge gives itself, if it does, to a message the app would otherwise answer.
40
+ function local(message) {
41
+ if (!offline) return undefined;
42
+ const { method, params } = message;
43
+ if (method === 'initialize') {
44
+ const asked = params?.protocolVersion;
45
+ return {
46
+ protocolVersion: offline.protocolVersions.includes(asked) ? asked : offline.protocolVersions[0],
47
+ capabilities: offline.capabilities,
48
+ serverInfo: { name: 'smartspec', version },
49
+ instructions: offline.instructions,
50
+ };
51
+ }
52
+ if (running) return undefined;
53
+ if (method === 'ping') return {};
54
+ if (method === 'tools/list') return { tools: offline.tools };
55
+ if (method === 'prompts/list') return { prompts: offline.prompts };
56
+ return undefined;
57
+ }
58
+
59
+ const sessionHeaders = () => ({
60
+ 'x-smartspec-session': session,
61
+ ...(client && { 'x-smartspec-client': client }),
62
+ ...(startedApp && { 'x-smartspec-started-app': '1' }),
63
+ });
64
+ // Tells the app about the session itself, once it's running.
65
+ async function notify(what) {
66
+ const res = await fetch(`${mcpUrl(env)}/${what}`, { method: 'POST', headers: { authorization: `Bearer ${token()}`, ...sessionHeaders() } });
67
+ await res.body?.cancel();
68
+ }
69
+
70
+ async function post(line) {
71
+ const res = await fetch(mcpUrl(env), {
72
+ method: 'POST',
73
+ headers: { ...sessionHeaders(), 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${token() ?? ''}` },
74
+ body: line,
75
+ });
76
+ return { status: res.status, text: (await res.text()).trim() };
77
+ }
78
+
79
+ async function forward(line, message) {
80
+ await whenReady();
81
+ let reply;
82
+ try {
83
+ reply = await post(line);
84
+ } catch {
85
+ // Nothing listening: the app was closed since. Open it again and retry once.
86
+ ready = null;
87
+ running = false;
88
+ await whenReady();
89
+ reply = await post(line);
90
+ }
91
+ if (reply.status >= 400) {
92
+ let reason = `The app refused the request (HTTP ${reply.status}).`;
93
+ try {
94
+ reason = JSON.parse(reply.text).error ?? reason;
95
+ } catch {}
96
+ return fail(message.id, reason);
97
+ }
98
+ if (reply.text) output.write(`${reply.text}\n`);
99
+ }
100
+
101
+ const pending = new Set();
102
+ const lines = readline.createInterface({ input });
103
+ lines.on('line', (line) => {
104
+ if (!line.trim()) return;
105
+ let message;
106
+ try {
107
+ message = JSON.parse(line);
108
+ } catch {
109
+ return;
110
+ }
111
+ // Notifications (no id) mean nothing to the app's server until it's running: it keeps no state
112
+ // between requests.
113
+ if (offline && message.id === undefined && !running) return;
114
+ if (message.method === 'initialize' && typeof message.params?.clientInfo?.name === 'string') {
115
+ client = message.params.clientInfo.name.replace(/[^\w .-]/g, '').slice(0, 60) || null;
116
+ }
117
+ const reply = local(message);
118
+ if (reply !== undefined) return answer(message.id, reply);
119
+ const job = forward(line, message).catch((error) => {
120
+ log(error.message);
121
+ // A tool call gets the reason as its result, which the AI reads and can pass on (say, that
122
+ // the app isn't installed).
123
+ if (message.method === 'tools/call') answer(message.id, { isError: true, content: [{ type: 'text', text: error.message }] });
124
+ else fail(message.id, error.message);
125
+ });
126
+ pending.add(job);
127
+ job.finally(() => pending.delete(job));
128
+ });
129
+
130
+ // The AI app has gone: so has this session.
131
+ return new Promise((resolve) => lines.on('close', () => Promise.allSettled([...pending]).then(async () => {
132
+ clearInterval(heartbeat);
133
+ if (running) await notify('end').catch(() => {});
134
+ resolve();
135
+ })));
136
+ }
@@ -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
+ // SmartSpec'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', 'smartspec'], { 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 SmartSpec 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', 'smartspec']);
34
+ await run('claude', ['mcp', 'add-json', '--scope', 'user', 'smartspec', JSON.stringify({ type: 'stdio', ...entry })]);
35
+ }
@@ -0,0 +1,180 @@
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 } 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, DOWNLOAD_PAGE } from './platform.js';
10
+
11
+ // The smartspec 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 SmartSpec can set up itself (Claude Desktop and Claude Code): setup adds SmartSpec 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 @smartspec/cli install (or download it from ${DOWNLOAD_PAGE}), then run this again.` };
25
+ const version = await appVersion(app);
26
+ return { app, version, entry: launcherFor(app) };
27
+ }
28
+
29
+ // Asks on the terminal; with --yes, or with no terminal to ask on, says yes.
30
+ export function terminalAsk({ yes = false } = {}) {
31
+ return async (question) => {
32
+ if (yes || !process.stdin.isTTY) return true;
33
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
34
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
35
+ rl.close();
36
+ return answer === '' || answer === 'y' || answer === 'yes';
37
+ };
38
+ }
39
+
40
+ // The download's progress on one line of the terminal, if there is one.
41
+ function progressLine(stream = process.stdout) {
42
+ if (!stream.isTTY) return { update() {}, done() {} };
43
+ let shown = -1;
44
+ return {
45
+ update(received, total) {
46
+ const percent = total ? Math.floor((received / total) * 100) : null;
47
+ if (percent === shown) return;
48
+ shown = percent;
49
+ const mb = (n) => Math.round(n / 1e6);
50
+ stream.write(`\r ${percent ?? '…'}% ${mb(received)}${total ? ` of ${mb(total)}` : ''} MB `);
51
+ },
52
+ done: () => stream.write('\r\x1b[K'),
53
+ };
54
+ }
55
+
56
+ export async function install({ out = console.log, ask = terminalAsk() } = {}) {
57
+ if (process.platform !== 'darwin') {
58
+ out(`${APP_NAME} runs on macOS for now.`);
59
+ return 1;
60
+ }
61
+ const { version, zip } = await latestRelease();
62
+ if (!zip) {
63
+ out(`The latest release (${version}) has no build for this Mac (${process.arch}).`);
64
+ return 1;
65
+ }
66
+ const dir = await installDir();
67
+ const target = path.join(dir, `${APP_NAME}.app`);
68
+ const current = existsSync(target) ? await appVersion(target) : null;
69
+ if (current === version) out(ok(`${APP_NAME} ${version} is already installed in ${target}`));
70
+ else {
71
+ if (existsSync(target) && !(await ask(`Replace ${APP_NAME} ${current ?? ''} in ${dir} with ${version}?`))) {
72
+ out('Left as it is.');
73
+ return 0;
74
+ }
75
+ if (await appRunning()) {
76
+ if (!(await ask(`${APP_NAME} is open. Quit it, to install ${version}?`))) {
77
+ out(`Quit ${APP_NAME}, then run this again.`);
78
+ return 1;
79
+ }
80
+ await quitApp();
81
+ }
82
+ out(`Downloading ${APP_NAME} ${version} for this Mac…`);
83
+ const line = progressLine();
84
+ try {
85
+ await installRelease(zip, dir, { progress: line.update });
86
+ } finally {
87
+ line.done();
88
+ }
89
+ out(ok(`Installed ${APP_NAME} ${version} in ${target}`));
90
+ }
91
+ if (await ask('\nSet up Claude Desktop and Claude Code to use it now?')) {
92
+ out('');
93
+ return setup({ out, ask });
94
+ }
95
+ out('Set them up later with: npx @smartspec/cli setup');
96
+ return 0;
97
+ }
98
+
99
+ export async function setup({ out = console.log, ask = terminalAsk() } = {}) {
100
+ const found = await installedApp();
101
+ if (found.problem) {
102
+ out(found.problem);
103
+ return 1;
104
+ }
105
+ out(`Setting up ${APP_NAME} ${found.version} (${found.app}).`);
106
+ let failed = false;
107
+
108
+ out('\nClaude Desktop');
109
+ const file = claudeDesktopConfig();
110
+ const desktop = await claudeDesktopStatus(file, found.entry);
111
+ if (desktop.state === 'not-installed') out(note('Not on this Mac.'));
112
+ else if (desktop.state === 'added') out(ok('Already set up.'));
113
+ else if (desktop.state === 'unreadable') {
114
+ out(bad(`Its settings file isn't in a form SmartSpec can safely change, so it was left alone: ${file}`));
115
+ failed = true;
116
+ } else if (await ask(` ${desktop.state === 'different' ? 'Update SmartSpec in' : 'Add SmartSpec to'} Claude Desktop's settings (a copy of the file is kept)?`)) {
117
+ await addToClaudeDesktop(file, found.entry);
118
+ out(ok('Done. Quit Claude Desktop and open it again to connect.'));
119
+ } else out(note('Skipped.'));
120
+
121
+ out('\nClaude Code');
122
+ if (!(await hasClaudeCode())) out(note('Not on this computer (no claude command).'));
123
+ else {
124
+ const details = await claudeCodeEntry();
125
+ if (claudeCodeRuns(details, found.entry)) out(ok('Already set up.'));
126
+ else if (await ask(` ${details ? 'Replace SmartSpec’s setup in' : 'Add SmartSpec to'} Claude Code, for all your projects?`)) {
127
+ try {
128
+ await addToClaudeCode(found.entry, { replace: Boolean(details) });
129
+ out(ok('Done. Start a new Claude Code session to connect.'));
130
+ } catch (error) {
131
+ out(bad(`Claude Code didn't take it: ${error.stderr?.trim() || error.message}`));
132
+ failed = true;
133
+ }
134
+ } else out(note('Skipped.'));
135
+ }
136
+
137
+ out(`\nFor other agents, open ${APP_NAME} and click its MCP server status, at the top right.`);
138
+ return failed ? 1 : 0;
139
+ }
140
+
141
+ export async function doctor({ out = console.log } = {}) {
142
+ let problems = 0;
143
+ const fail = (text, fix) => {
144
+ problems++;
145
+ out(bad(text));
146
+ if (fix) out(note(fix));
147
+ };
148
+
149
+ out(`${APP_NAME}`);
150
+ const found = await installedApp();
151
+ if (found.problem) {
152
+ fail(found.problem);
153
+ return 1;
154
+ }
155
+ out(ok(`${APP_NAME} ${found.version} is installed in ${found.app}`));
156
+ const state = await probe();
157
+ if (state === 'up') out(ok(`${APP_NAME} is running, and its MCP server answers`));
158
+ else if (state === 'locked') fail(`Something answers on SmartSpec's port, but not with SmartSpec's token`, 'Quit SmartSpec and open it again.');
159
+ else out(note(`${APP_NAME} isn't running. That's fine: your agent opens it when it needs it.`));
160
+
161
+ out('\nClaude Desktop');
162
+ const desktop = await claudeDesktopStatus(claudeDesktopConfig(), found.entry);
163
+ if (desktop.state === 'not-installed') out(note('Not on this Mac.'));
164
+ else if (desktop.state === 'added') out(ok('Set up for SmartSpec where it is now'));
165
+ else if (desktop.state === 'not-added') fail('SmartSpec isn’t set up in Claude Desktop', 'Run: npx @smartspec/cli setup');
166
+ else if (desktop.state === 'different') fail('Claude Desktop is set up for SmartSpec somewhere else (or the old way)', 'Run: npx @smartspec/cli setup');
167
+ else fail(`Its settings file isn't valid JSON: ${desktop.file}`, 'Fix or remove the file, then run: npx @smartspec/cli setup');
168
+
169
+ out('\nClaude Code');
170
+ if (!(await hasClaudeCode())) out(note('Not on this computer (no claude command).'));
171
+ else {
172
+ const details = await claudeCodeEntry();
173
+ if (claudeCodeRuns(details, found.entry)) out(ok('Set up for SmartSpec where it is now'));
174
+ else if (details) fail('Claude Code is set up for SmartSpec somewhere else (or the old way)', 'Run: npx @smartspec/cli setup');
175
+ else fail('SmartSpec isn’t set up in Claude Code', 'Run: npx @smartspec/cli setup');
176
+ }
177
+
178
+ out(problems ? `\n${problems} thing${problems === 1 ? '' : 's'} to fix.` : '\nAll good.');
179
+ return problems ? 1 : 0;
180
+ }