@specship/specship-win32-arm64 0.12.0 → 0.13.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/lib/dist/bin/specship.js +48 -12
- package/lib/dist/bin/specship.js.map +1 -1
- package/lib/dist/db/migrations.d.ts +1 -1
- package/lib/dist/db/migrations.d.ts.map +1 -1
- package/lib/dist/db/migrations.js +16 -1
- package/lib/dist/db/migrations.js.map +1 -1
- package/lib/dist/db/schema.sql +10 -0
- package/lib/dist/db/spec-queries.d.ts +6 -0
- package/lib/dist/db/spec-queries.d.ts.map +1 -1
- package/lib/dist/db/spec-queries.js +8 -0
- package/lib/dist/db/spec-queries.js.map +1 -1
- package/lib/dist/index.d.ts +8 -0
- package/lib/dist/index.d.ts.map +1 -1
- package/lib/dist/index.js +9 -0
- package/lib/dist/index.js.map +1 -1
- package/lib/dist/server/cli.js +9 -7
- package/lib/dist/server/product-version.js +44 -0
- package/lib/dist/server/routes/claude.js +153 -42
- package/lib/dist/server/routes/config.js +35 -0
- package/lib/dist/server/routes/graph.js +22 -1
- package/lib/dist/server/routes/mcp.js +510 -0
- package/lib/dist/server/routes/projects.js +31 -3
- package/lib/dist/server/routes/status.js +3 -0
- package/lib/dist/server/routes/workflow.js +44 -3
- package/lib/dist/server/server-config.js +38 -0
- package/lib/dist/server/server.js +128 -58
- package/lib/dist/statusline/active-run.d.ts +2 -2
- package/lib/dist/statusline/active-run.d.ts.map +1 -1
- package/lib/dist/statusline/active-run.js +2 -2
- package/lib/dist/statusline/active-run.js.map +1 -1
- package/lib/dist/statusline/render.d.ts.map +1 -1
- package/lib/dist/statusline/render.js +41 -5
- package/lib/dist/statusline/render.js.map +1 -1
- package/lib/dist/statusline/types.d.ts +15 -0
- package/lib/dist/statusline/types.d.ts.map +1 -1
- package/lib/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/lib/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/lib/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/lib/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/lib/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- package/lib/dist/ui/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- package/lib/dist/ui/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- package/lib/dist/ui/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- package/lib/dist/ui/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- package/lib/dist/ui/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- package/lib/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/lib/dist/ui/assets/index-D8e4LiVb.js +51 -0
- package/lib/dist/ui/assets/index-ezwX2EfG.css +1 -0
- package/lib/dist/ui/favicon-16.png +0 -0
- package/lib/dist/ui/favicon-180.png +0 -0
- package/lib/dist/ui/favicon-32.png +0 -0
- package/lib/dist/ui/favicon.svg +20 -0
- package/lib/dist/ui/index.html +17 -0
- package/lib/dist/update/resolve-latest.d.ts +14 -0
- package/lib/dist/update/resolve-latest.d.ts.map +1 -0
- package/lib/dist/update/resolve-latest.js +29 -0
- package/lib/dist/update/resolve-latest.js.map +1 -0
- package/lib/dist/update/run-installer.d.ts +13 -0
- package/lib/dist/update/run-installer.d.ts.map +1 -0
- package/lib/dist/update/run-installer.js +43 -0
- package/lib/dist/update/run-installer.js.map +1 -0
- package/lib/dist/update/updater.d.ts +53 -0
- package/lib/dist/update/updater.d.ts.map +1 -0
- package/lib/dist/update/updater.js +164 -0
- package/lib/dist/update/updater.js.map +1 -0
- package/lib/dist/workflows/eta.d.ts +42 -0
- package/lib/dist/workflows/eta.d.ts.map +1 -0
- package/lib/dist/workflows/eta.js +146 -0
- package/lib/dist/workflows/eta.js.map +1 -0
- package/lib/dist/workflows/executor.d.ts.map +1 -1
- package/lib/dist/workflows/executor.js +25 -6
- package/lib/dist/workflows/executor.js.map +1 -1
- package/lib/dist/workflows/runners/prompt.d.ts +6 -0
- package/lib/dist/workflows/runners/prompt.d.ts.map +1 -1
- package/lib/dist/workflows/runners/prompt.js +10 -0
- package/lib/dist/workflows/runners/prompt.js.map +1 -1
- package/lib/dist/workflows/runners/types.d.ts +12 -0
- package/lib/dist/workflows/runners/types.d.ts.map +1 -1
- package/lib/node_modules/.package-lock.json +1 -1
- package/lib/package.json +3 -2
- package/package.json +1 -1
- package/lib/dist/server/ssr/md.mjs +0 -83
- package/lib/dist/server/ssr/public/app.css +0 -1
- package/lib/dist/server/ssr/render.mjs +0 -564
- package/lib/dist/server/ssr/routes.js +0 -73
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP servers routes — inventory, live status, and control (REQ-DESKTOP-026).
|
|
3
|
+
*
|
|
4
|
+
* Three real sources, merged (this fork is Claude Code only, so Claude
|
|
5
|
+
* Code's config surfaces ARE the inventory — no other clients are read):
|
|
6
|
+
* 1. Claude Code's own config — `~/.claude.json` (global `mcpServers` +
|
|
7
|
+
* `projects[<root>].mcpServers`) and `<projectRoot>/.mcp.json`, the
|
|
8
|
+
* same files `specship install` writes. Disabled servers come from
|
|
9
|
+
* `disabledMcpjsonServers` (Claude Code's native mechanism for
|
|
10
|
+
* `.mcp.json` entries) and `mcpServersDisabled` (our parking map for
|
|
11
|
+
* `~/.claude.json`-owned entries).
|
|
12
|
+
* 2. The transcript analytics DB — `claude_tool_calls` rows whose
|
|
13
|
+
* `tool_name` is `mcp__<server>__<tool>` give real per-server/per-tool
|
|
14
|
+
* call counts, result sizes, last-used timestamps, and the example
|
|
15
|
+
* call (`input_json`, schema v7).
|
|
16
|
+
* 3. A liveness probe — stdio entries are spawned and asked to answer an
|
|
17
|
+
* MCP `initialize`; `url` entries get a fetch. Results cache ~30 s.
|
|
18
|
+
*
|
|
19
|
+
* States use the spec vocabulary (deriveState): `disabled` (parked in
|
|
20
|
+
* config) → `failed` (probe refused) → `active` (calls in the last 24 h) →
|
|
21
|
+
* `connected` (probe answered) → `idle`.
|
|
22
|
+
*
|
|
23
|
+
* Writes (PATCH enable/disable, POST add) touch the user's LIVE Claude Code
|
|
24
|
+
* configs, so every write is atomic (write-temp + rename) and preserves
|
|
25
|
+
* unrelated keys byte-for-byte: key order is kept, and the file's existing
|
|
26
|
+
* indentation unit + trailing newline are detected and reused. A file that
|
|
27
|
+
* fails to parse is never overwritten — the edit errors instead.
|
|
28
|
+
*/
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import * as fs from 'node:fs';
|
|
31
|
+
import * as os from 'node:os';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
function getDb(cg) {
|
|
34
|
+
const anyCg = cg;
|
|
35
|
+
if (anyCg.db?.getDb)
|
|
36
|
+
return anyCg.db.getDb();
|
|
37
|
+
return anyCg.queries?.db;
|
|
38
|
+
}
|
|
39
|
+
/** Config-write failures the routes translate to HTTP statuses. */
|
|
40
|
+
export class McpConfigError extends Error {
|
|
41
|
+
statusCode;
|
|
42
|
+
constructor(message, statusCode) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.statusCode = statusCode;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function readJson(p) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Collect configured servers from Claude Code's three config surfaces.
|
|
57
|
+
* Precedence on name collision follows Claude Code's own resolution:
|
|
58
|
+
* user (`~/.claude.json` global) < project-local (`projects[<root>]` entry)
|
|
59
|
+
* < `.mcp.json` — later wins, matching what the agent actually loads.
|
|
60
|
+
*/
|
|
61
|
+
export function configuredServers(paths) {
|
|
62
|
+
const out = new Map();
|
|
63
|
+
const claudeJson = readJson(paths.claudeJsonPath);
|
|
64
|
+
const add = (name, cfg) => out.set(name, { name, ...cfg });
|
|
65
|
+
for (const [name, entry] of Object.entries((claudeJson?.['mcpServers'] ?? {}))) {
|
|
66
|
+
add(name, { scope: 'user', entry, configFile: paths.claudeJsonPath, disabled: false, container: 'user' });
|
|
67
|
+
}
|
|
68
|
+
for (const [name, entry] of Object.entries((claudeJson?.['mcpServersDisabled'] ?? {}))) {
|
|
69
|
+
add(name, { scope: 'user', entry, configFile: paths.claudeJsonPath, disabled: true, container: 'user' });
|
|
70
|
+
}
|
|
71
|
+
if (paths.projectRoot) {
|
|
72
|
+
const proj = claudeJson?.['projects']?.[paths.projectRoot];
|
|
73
|
+
for (const [name, entry] of Object.entries((proj?.['mcpServers'] ?? {}))) {
|
|
74
|
+
add(name, { scope: 'project', entry, configFile: paths.claudeJsonPath, disabled: false, container: 'project-local' });
|
|
75
|
+
}
|
|
76
|
+
for (const [name, entry] of Object.entries((proj?.['mcpServersDisabled'] ?? {}))) {
|
|
77
|
+
add(name, { scope: 'project', entry, configFile: paths.claudeJsonPath, disabled: true, container: 'project-local' });
|
|
78
|
+
}
|
|
79
|
+
const disabledMcpjson = new Set((proj?.['disabledMcpjsonServers'] ?? []));
|
|
80
|
+
const mcpJsonPath = path.join(paths.projectRoot, '.mcp.json');
|
|
81
|
+
const mcpJson = readJson(mcpJsonPath);
|
|
82
|
+
for (const [name, entry] of Object.entries((mcpJson?.['mcpServers'] ?? {}))) {
|
|
83
|
+
add(name, { scope: 'project', entry, configFile: mcpJsonPath, disabled: disabledMcpjson.has(name), container: 'mcpjson' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Spec vocabulary, in precedence order: a parked entry is `disabled` no
|
|
90
|
+
* matter what; a refused probe is `failed` even with recent calls (the
|
|
91
|
+
* config broke since); recent usage is `active`; a probe answer without
|
|
92
|
+
* recent usage is `connected`; otherwise `idle`.
|
|
93
|
+
*/
|
|
94
|
+
export function deriveState(cfg, usage, probe, nowMs = Date.now()) {
|
|
95
|
+
if (cfg?.disabled)
|
|
96
|
+
return 'disabled';
|
|
97
|
+
if (probe === 'failed')
|
|
98
|
+
return 'failed';
|
|
99
|
+
if (usage && nowMs - usage.lastMs < 24 * 3600_000)
|
|
100
|
+
return 'active';
|
|
101
|
+
if (probe === 'ok')
|
|
102
|
+
return 'connected';
|
|
103
|
+
return 'idle';
|
|
104
|
+
}
|
|
105
|
+
const PROBE_CACHE_MS = 30_000;
|
|
106
|
+
const probeCache = new Map();
|
|
107
|
+
/** The MCP initialize request the stdio probe sends (newline-delimited JSON-RPC). */
|
|
108
|
+
const INITIALIZE_REQUEST = JSON.stringify({
|
|
109
|
+
jsonrpc: '2.0',
|
|
110
|
+
id: 1,
|
|
111
|
+
method: 'initialize',
|
|
112
|
+
params: {
|
|
113
|
+
protocolVersion: '2025-06-18',
|
|
114
|
+
capabilities: {},
|
|
115
|
+
clientInfo: { name: 'specship-desktop', version: '0.0.0' },
|
|
116
|
+
},
|
|
117
|
+
}) + '\n';
|
|
118
|
+
function probeStdio(entry, timeoutMs) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
let settled = false;
|
|
121
|
+
let child;
|
|
122
|
+
const done = (result) => {
|
|
123
|
+
if (settled)
|
|
124
|
+
return;
|
|
125
|
+
settled = true;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
try {
|
|
128
|
+
child?.kill();
|
|
129
|
+
}
|
|
130
|
+
catch { /* already gone */ }
|
|
131
|
+
resolve(result);
|
|
132
|
+
};
|
|
133
|
+
const timer = setTimeout(() => done('failed'), timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
child = spawn(entry.command, entry.args ?? [], {
|
|
136
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
137
|
+
env: { ...process.env, ...(entry.env ?? {}) },
|
|
138
|
+
shell: process.platform === 'win32',
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
done('failed');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
child.on('error', () => done('failed'));
|
|
147
|
+
child.on('exit', () => done('failed'));
|
|
148
|
+
let buf = '';
|
|
149
|
+
child.stdout?.on('data', (chunk) => {
|
|
150
|
+
buf += chunk.toString('utf8');
|
|
151
|
+
for (const line of buf.split('\n')) {
|
|
152
|
+
try {
|
|
153
|
+
const msg = JSON.parse(line);
|
|
154
|
+
if (msg && msg.id === 1 && msg.result) {
|
|
155
|
+
done('ok');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch { /* partial line */ }
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
child.stdin?.write(INITIALIZE_REQUEST);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
done('failed');
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async function probeUrl(url, timeoutMs) {
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
173
|
+
// Any 4xx/5xx (expired OAuth = 401, dead route = 404) is the failed
|
|
174
|
+
// treatment — the design's sentry example shows 401 as Failed.
|
|
175
|
+
return res.ok ? 'ok' : 'failed';
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return 'failed';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Liveness probe. Never throws and never hangs past `timeoutMs`; a timeout
|
|
183
|
+
* counts as `failed` (the agent couldn't have used it either). Results are
|
|
184
|
+
* cached ~30 s per config shape so a page refresh doesn't respawn servers.
|
|
185
|
+
*/
|
|
186
|
+
export async function probeServer(entry, timeoutMs = 2500) {
|
|
187
|
+
const key = entry.url ?? [entry.command, ...(entry.args ?? [])].join(' ');
|
|
188
|
+
if (!key)
|
|
189
|
+
return 'failed';
|
|
190
|
+
const cached = probeCache.get(key);
|
|
191
|
+
if (cached && Date.now() - cached.at < PROBE_CACHE_MS)
|
|
192
|
+
return cached.result;
|
|
193
|
+
const result = entry.url
|
|
194
|
+
? await probeUrl(entry.url, timeoutMs)
|
|
195
|
+
: entry.command
|
|
196
|
+
? await probeStdio(entry, timeoutMs)
|
|
197
|
+
: 'failed';
|
|
198
|
+
probeCache.set(key, { at: Date.now(), result });
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
// ---- Config writes ----------------------------------------------------------
|
|
202
|
+
/** Write-temp + rename so a crash mid-write can't corrupt a live config. */
|
|
203
|
+
function atomicWriteFileSync(filePath, content) {
|
|
204
|
+
const dir = path.dirname(filePath);
|
|
205
|
+
if (!fs.existsSync(dir))
|
|
206
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
207
|
+
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
208
|
+
try {
|
|
209
|
+
fs.writeFileSync(tmpPath, content);
|
|
210
|
+
fs.renameSync(tmpPath, filePath);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
try {
|
|
214
|
+
fs.unlinkSync(tmpPath);
|
|
215
|
+
}
|
|
216
|
+
catch { /* ignore */ }
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** First indented line's leading whitespace = the file's indent unit. */
|
|
221
|
+
function detectJsonStyle(text) {
|
|
222
|
+
const m = text.match(/\n([ \t]+)\S/);
|
|
223
|
+
return {
|
|
224
|
+
indent: m?.[1] ?? ' ',
|
|
225
|
+
trailingNewline: text.length === 0 || text.endsWith('\n'),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Parse → mutate → re-serialize preserving key order, the detected indent
|
|
230
|
+
* unit, and the trailing newline — so untouched keys keep their bytes. An
|
|
231
|
+
* unparseable file throws (500) instead of being clobbered.
|
|
232
|
+
*/
|
|
233
|
+
function editJsonFile(filePath, mutate) {
|
|
234
|
+
let text = '';
|
|
235
|
+
try {
|
|
236
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
237
|
+
}
|
|
238
|
+
catch { /* missing → create */ }
|
|
239
|
+
let data = {};
|
|
240
|
+
if (text.trim()) {
|
|
241
|
+
try {
|
|
242
|
+
data = JSON.parse(text);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
246
|
+
throw new McpConfigError(`refusing to edit unparseable ${filePath}: ${msg}`, 500);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
mutate(data);
|
|
250
|
+
const { indent, trailingNewline } = detectJsonStyle(text);
|
|
251
|
+
atomicWriteFileSync(filePath, JSON.stringify(data, null, indent) + (trailingNewline ? '\n' : ''));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Move `data[from][name]` → `data[to][name]`. An emptied `mcpServersDisabled`
|
|
255
|
+
* (our parking map) is removed; an emptied `mcpServers` is kept in place —
|
|
256
|
+
* deleting it would re-create it at the END of the object on re-enable,
|
|
257
|
+
* breaking the byte-for-byte round-trip.
|
|
258
|
+
*/
|
|
259
|
+
function moveEntry(data, from, to, name) {
|
|
260
|
+
const entry = data[from]?.[name];
|
|
261
|
+
if (entry === undefined)
|
|
262
|
+
throw new McpConfigError(`server "${name}" not found under ${from}`, 404);
|
|
263
|
+
delete data[from][name];
|
|
264
|
+
if (from === 'mcpServersDisabled' && Object.keys(data[from]).length === 0)
|
|
265
|
+
delete data[from];
|
|
266
|
+
data[to] = { ...(data[to] ?? {}), [name]: entry };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Enable/disable a configured server by editing its owning file.
|
|
270
|
+
* - `.mcp.json`-owned → toggle the name in `disabledMcpjsonServers` under
|
|
271
|
+
* the project's entry in `~/.claude.json` (Claude Code's own mechanism —
|
|
272
|
+
* `.mcp.json` itself is team-shared and never touched).
|
|
273
|
+
* - `~/.claude.json`-owned (user or project-local) → move the entry
|
|
274
|
+
* between `mcpServers` and the `mcpServersDisabled` parking map.
|
|
275
|
+
* Idempotent: a no-op toggle returns without writing. Throws McpConfigError
|
|
276
|
+
* 404 when the name isn't configured anywhere.
|
|
277
|
+
*/
|
|
278
|
+
export function setServerEnabled(paths, name, enabled) {
|
|
279
|
+
const cfg = configuredServers(paths).get(name);
|
|
280
|
+
if (!cfg)
|
|
281
|
+
throw new McpConfigError(`unknown MCP server "${name}"`, 404);
|
|
282
|
+
if (cfg.disabled === !enabled)
|
|
283
|
+
return cfg;
|
|
284
|
+
if (cfg.container === 'mcpjson') {
|
|
285
|
+
editJsonFile(paths.claudeJsonPath, (data) => {
|
|
286
|
+
const projects = (data['projects'] = data['projects'] ?? {});
|
|
287
|
+
const proj = (projects[paths.projectRoot] = projects[paths.projectRoot] ?? {});
|
|
288
|
+
const list = proj['disabledMcpjsonServers'] ?? [];
|
|
289
|
+
const next = enabled ? list.filter((n) => n !== name) : [...list, name];
|
|
290
|
+
if (next.length)
|
|
291
|
+
proj['disabledMcpjsonServers'] = next;
|
|
292
|
+
else
|
|
293
|
+
delete proj['disabledMcpjsonServers'];
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else if (cfg.container === 'user') {
|
|
297
|
+
editJsonFile(paths.claudeJsonPath, (data) => {
|
|
298
|
+
if (enabled)
|
|
299
|
+
moveEntry(data, 'mcpServersDisabled', 'mcpServers', name);
|
|
300
|
+
else
|
|
301
|
+
moveEntry(data, 'mcpServers', 'mcpServersDisabled', name);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
editJsonFile(paths.claudeJsonPath, (data) => {
|
|
306
|
+
const proj = data['projects']?.[paths.projectRoot];
|
|
307
|
+
if (!proj)
|
|
308
|
+
throw new McpConfigError(`project entry for ${paths.projectRoot} missing`, 404);
|
|
309
|
+
if (enabled)
|
|
310
|
+
moveEntry(proj, 'mcpServersDisabled', 'mcpServers', name);
|
|
311
|
+
else
|
|
312
|
+
moveEntry(proj, 'mcpServers', 'mcpServersDisabled', name);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return { ...cfg, disabled: !enabled };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Add a server: user scope → `~/.claude.json` `mcpServers`; project scope →
|
|
319
|
+
* `<projectRoot>/.mcp.json`. 409 when the name exists on any surface, 400
|
|
320
|
+
* on invalid input. Entry shape matches the installer's (`type` explicit).
|
|
321
|
+
*/
|
|
322
|
+
export function addServer(paths, input) {
|
|
323
|
+
const name = (input.name ?? '').trim();
|
|
324
|
+
if (!name || /\s/.test(name))
|
|
325
|
+
throw new McpConfigError('server name must be a non-empty token', 400);
|
|
326
|
+
if (input.scope !== 'user' && input.scope !== 'project')
|
|
327
|
+
throw new McpConfigError('scope must be "user" or "project"', 400);
|
|
328
|
+
if (!input.url && !(input.command ?? '').trim())
|
|
329
|
+
throw new McpConfigError('either a command or a url is required', 400);
|
|
330
|
+
if (configuredServers(paths).has(name))
|
|
331
|
+
throw new McpConfigError(`MCP server "${name}" is already configured`, 409);
|
|
332
|
+
const entry = input.url
|
|
333
|
+
? { type: 'http', url: input.url }
|
|
334
|
+
: { type: 'stdio', command: input.command.trim(), ...(input.args?.length ? { args: input.args } : {}) };
|
|
335
|
+
if (input.scope === 'project') {
|
|
336
|
+
if (!paths.projectRoot)
|
|
337
|
+
throw new McpConfigError('no project open — cannot write .mcp.json', 400);
|
|
338
|
+
const mcpJsonPath = path.join(paths.projectRoot, '.mcp.json');
|
|
339
|
+
editJsonFile(mcpJsonPath, (data) => {
|
|
340
|
+
data['mcpServers'] = { ...(data['mcpServers'] ?? {}), [name]: entry };
|
|
341
|
+
});
|
|
342
|
+
return { name, scope: 'project', entry, configFile: mcpJsonPath, disabled: false, container: 'mcpjson' };
|
|
343
|
+
}
|
|
344
|
+
editJsonFile(paths.claudeJsonPath, (data) => {
|
|
345
|
+
data['mcpServers'] = { ...(data['mcpServers'] ?? {}), [name]: entry };
|
|
346
|
+
});
|
|
347
|
+
return { name, scope: 'user', entry, configFile: paths.claudeJsonPath, disabled: false, container: 'user' };
|
|
348
|
+
}
|
|
349
|
+
/** Aggregate claude_tool_calls into per-server usage + the latest example call. */
|
|
350
|
+
function collectUsage(cg) {
|
|
351
|
+
const byServer = new Map();
|
|
352
|
+
let usage = [];
|
|
353
|
+
let examples = [];
|
|
354
|
+
try {
|
|
355
|
+
const db = getDb(cg);
|
|
356
|
+
usage = db.prepare(`
|
|
357
|
+
SELECT tool_name, COUNT(*) as calls,
|
|
358
|
+
COALESCE(SUM(result_length), 0) as bytes,
|
|
359
|
+
MAX(ts) as last
|
|
360
|
+
FROM claude_tool_calls
|
|
361
|
+
WHERE tool_name LIKE 'mcp__%'
|
|
362
|
+
GROUP BY tool_name
|
|
363
|
+
`).all();
|
|
364
|
+
// Latest verbatim inputs (schema v7) — first row per server wins below.
|
|
365
|
+
examples = db.prepare(`
|
|
366
|
+
SELECT tool_name, input_json
|
|
367
|
+
FROM claude_tool_calls
|
|
368
|
+
WHERE tool_name LIKE 'mcp__%' AND input_json IS NOT NULL
|
|
369
|
+
ORDER BY ts DESC
|
|
370
|
+
LIMIT 500
|
|
371
|
+
`).all();
|
|
372
|
+
}
|
|
373
|
+
catch { /* analytics tables absent — config-only response */ }
|
|
374
|
+
const parse = (t) => t.match(/^mcp__(.+?)__(.+)$/);
|
|
375
|
+
for (const u of usage) {
|
|
376
|
+
const m = parse(u.tool_name);
|
|
377
|
+
if (!m)
|
|
378
|
+
continue;
|
|
379
|
+
const [, server, tool] = m;
|
|
380
|
+
if (!byServer.has(server))
|
|
381
|
+
byServer.set(server, { calls: 0, bytes: 0, last: 0, tools: new Map(), example: null });
|
|
382
|
+
const s = byServer.get(server);
|
|
383
|
+
s.calls += u.calls;
|
|
384
|
+
s.bytes += u.bytes;
|
|
385
|
+
// `ts` is epoch milliseconds (integer) in the ingest schema.
|
|
386
|
+
const t = typeof u.last === 'number' ? u.last : u.last ? Date.parse(u.last) : 0;
|
|
387
|
+
if (Number.isFinite(t) && t > s.last)
|
|
388
|
+
s.last = t;
|
|
389
|
+
const tt = s.tools.get(tool) ?? { calls: 0, bytes: 0 };
|
|
390
|
+
tt.calls += u.calls;
|
|
391
|
+
tt.bytes += u.bytes;
|
|
392
|
+
s.tools.set(tool, tt);
|
|
393
|
+
}
|
|
394
|
+
for (const row of examples) {
|
|
395
|
+
const m = parse(row.tool_name);
|
|
396
|
+
if (!m)
|
|
397
|
+
continue;
|
|
398
|
+
const [, server, tool] = m;
|
|
399
|
+
const s = byServer.get(server);
|
|
400
|
+
if (!s || s.example)
|
|
401
|
+
continue;
|
|
402
|
+
try {
|
|
403
|
+
s.example = { tool, args: JSON.parse(row.input_json) };
|
|
404
|
+
}
|
|
405
|
+
catch { /* skip bad row */ }
|
|
406
|
+
}
|
|
407
|
+
// Fallback example: the server's top tool with empty args.
|
|
408
|
+
for (const s of byServer.values()) {
|
|
409
|
+
if (s.example)
|
|
410
|
+
continue;
|
|
411
|
+
const top = [...s.tools.entries()].sort((a, b) => b[1].calls - a[1].calls)[0];
|
|
412
|
+
if (top)
|
|
413
|
+
s.example = { tool: top[0], args: {} };
|
|
414
|
+
}
|
|
415
|
+
return byServer;
|
|
416
|
+
}
|
|
417
|
+
export async function registerMcpRoutes(app) {
|
|
418
|
+
const paths = () => ({
|
|
419
|
+
claudeJsonPath: path.join(os.homedir(), '.claude.json'),
|
|
420
|
+
projectRoot: app.primaryCg?.getProjectRoot?.() ?? null,
|
|
421
|
+
});
|
|
422
|
+
function requirePrimary(reply) {
|
|
423
|
+
if (!app.primaryCg) {
|
|
424
|
+
reply.code(409).send({ error: 'analytics unavailable: no primary project configured', code: 'no_primary' });
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
return app.primaryCg;
|
|
428
|
+
}
|
|
429
|
+
app.get('/api/mcp/servers', async (_req, reply) => {
|
|
430
|
+
const cg = requirePrimary(reply);
|
|
431
|
+
if (!cg)
|
|
432
|
+
return;
|
|
433
|
+
const p = paths();
|
|
434
|
+
const config = configuredServers(p);
|
|
435
|
+
const byServer = collectUsage(cg);
|
|
436
|
+
// Probe every enabled configured server concurrently; disabled and
|
|
437
|
+
// usage-only entries have nothing to probe.
|
|
438
|
+
const probeTargets = [...config.values()].filter((c) => !c.disabled);
|
|
439
|
+
const probes = new Map(await Promise.all(probeTargets.map(async (c) => [c.name, await probeServer(c.entry)])));
|
|
440
|
+
const names = new Set([...config.keys(), ...byServer.keys()]);
|
|
441
|
+
const servers = [...names].map((name) => {
|
|
442
|
+
const cfg = config.get(name);
|
|
443
|
+
const use = byServer.get(name);
|
|
444
|
+
const entry = cfg?.entry ?? {};
|
|
445
|
+
const transport = entry.url ? 'http' : 'stdio';
|
|
446
|
+
const command = entry.url ?? [entry.command, ...(entry.args ?? [])].filter(Boolean).join(' ');
|
|
447
|
+
const state = deriveState(cfg, use?.last ? { lastMs: use.last } : null, probes.get(name) ?? null);
|
|
448
|
+
return {
|
|
449
|
+
id: name, name,
|
|
450
|
+
scope: cfg?.scope ?? 'unknown',
|
|
451
|
+
state, transport, command,
|
|
452
|
+
configFile: cfg?.configFile ?? null,
|
|
453
|
+
disabled: cfg?.disabled ?? false,
|
|
454
|
+
entry: cfg?.entry ?? null,
|
|
455
|
+
calls: use?.calls ?? 0,
|
|
456
|
+
resultBytes: use?.bytes ?? 0,
|
|
457
|
+
lastUsed: use?.last ? new Date(use.last).toISOString() : null,
|
|
458
|
+
tools: use ? [...use.tools.entries()]
|
|
459
|
+
.map(([tool, t]) => ({ name: tool, calls: t.calls, resultBytes: t.bytes }))
|
|
460
|
+
.sort((a, b) => b.calls - a.calls) : [],
|
|
461
|
+
exampleCall: use?.example ?? null,
|
|
462
|
+
};
|
|
463
|
+
}).sort((a, b) => b.calls - a.calls);
|
|
464
|
+
reply.send({ servers });
|
|
465
|
+
});
|
|
466
|
+
app.patch('/api/mcp/servers/:name', async (req, reply) => {
|
|
467
|
+
const enabled = req.body?.enabled;
|
|
468
|
+
if (typeof enabled !== 'boolean') {
|
|
469
|
+
return reply.code(400).send({ error: 'body must be { enabled: boolean }' });
|
|
470
|
+
}
|
|
471
|
+
const p = paths();
|
|
472
|
+
try {
|
|
473
|
+
const server = setServerEnabled(p, req.params.name, enabled);
|
|
474
|
+
return reply.send({ ok: true, name: server.name, disabled: server.disabled });
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
if (err instanceof McpConfigError) {
|
|
478
|
+
// Usage-only servers show up in transcripts but have no config to
|
|
479
|
+
// edit — that's a 400 (bad target), not a 404 (unknown name).
|
|
480
|
+
if (err.statusCode === 404 && app.primaryCg && hasUsage(app.primaryCg, req.params.name)) {
|
|
481
|
+
return reply.code(400).send({ error: `"${req.params.name}" appears in transcripts but has no config entry to toggle` });
|
|
482
|
+
}
|
|
483
|
+
return reply.code(err.statusCode).send({ error: err.message });
|
|
484
|
+
}
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
app.post('/api/mcp/servers', async (req, reply) => {
|
|
489
|
+
try {
|
|
490
|
+
const server = addServer(paths(), req.body ?? {});
|
|
491
|
+
return reply.code(201).send({ ok: true, name: server.name, scope: server.scope, configFile: server.configFile });
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
if (err instanceof McpConfigError)
|
|
495
|
+
return reply.code(err.statusCode).send({ error: err.message });
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/** True when transcripts contain any mcp__<name>__* call. */
|
|
501
|
+
function hasUsage(cg, name) {
|
|
502
|
+
try {
|
|
503
|
+
const db = getDb(cg);
|
|
504
|
+
const rows = db.prepare(`SELECT 1 FROM claude_tool_calls WHERE tool_name LIKE 'mcp__' || ? || '__%' LIMIT 1`).all(name);
|
|
505
|
+
return rows.length > 0;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -68,7 +68,7 @@ export async function enumerate(claudeRoot, resolverOpts) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
catch { /* path missing — OK, still surface it so UI can flag it */ }
|
|
71
|
-
out.push({ slug, path: decoded, exists, initialized, sessionCount, lastModifiedMs });
|
|
71
|
+
out.push({ slug, path: decoded, exists, initialized, sessionCount, lastModifiedMs, driftCount: null });
|
|
72
72
|
}
|
|
73
73
|
// Recent activity first; ties broken by path.
|
|
74
74
|
out.sort((a, b) => {
|
|
@@ -78,6 +78,32 @@ export async function enumerate(claudeRoot, resolverOpts) {
|
|
|
78
78
|
});
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Bound on how many projects get a live driftCount per request. Mirrors the
|
|
83
|
+
* /api/events sweep: every lookup opens the project's graph through the
|
|
84
|
+
* registry LRU (maxOpen 16), so an unbounded pass over a large project list
|
|
85
|
+
* churns open/close cycles on every GET. enumerate() sorts by recency, so
|
|
86
|
+
* the bounded prefix is exactly what the switcher surfaces first; the rest
|
|
87
|
+
* stay `null` (unknown), which the UI renders as no pill.
|
|
88
|
+
*/
|
|
89
|
+
const DRIFT_SWEEP_LIMIT = 10;
|
|
90
|
+
/** Fill `driftCount` in place for the most-recent initialized projects. */
|
|
91
|
+
export async function attachDriftCounts(list, source, limit = DRIFT_SWEEP_LIMIT) {
|
|
92
|
+
let budget = limit;
|
|
93
|
+
for (const p of list) {
|
|
94
|
+
if (!p.initialized || budget <= 0)
|
|
95
|
+
continue;
|
|
96
|
+
budget--;
|
|
97
|
+
try {
|
|
98
|
+
const cg = await source(p.slug);
|
|
99
|
+
if (!cg)
|
|
100
|
+
continue; // open failed — leave null
|
|
101
|
+
p.driftCount = cg.getSpecQueries().getLinksByState(['drifted', 'broken', 'orphaned']).length;
|
|
102
|
+
}
|
|
103
|
+
catch { /* graph unreadable — leave null */ }
|
|
104
|
+
}
|
|
105
|
+
return list;
|
|
106
|
+
}
|
|
81
107
|
// --- Watcher (singleton across the process) ---------------------------------
|
|
82
108
|
let watcher = null;
|
|
83
109
|
let lastSlugs = new Set();
|
|
@@ -144,8 +170,10 @@ export async function registerProjectsRoutes(app) {
|
|
|
144
170
|
const claudeRoot = path.join(os.homedir(), '.claude', 'projects');
|
|
145
171
|
startWatcher(claudeRoot, false);
|
|
146
172
|
app.addHook('onClose', async () => { stopWatcher(); });
|
|
173
|
+
const driftSource = (slug) => app.projects.getBySlug(slug);
|
|
147
174
|
app.get('/api/projects', async () => {
|
|
148
|
-
|
|
175
|
+
const projects = await attachDriftCounts(await enumerate(claudeRoot), driftSource);
|
|
176
|
+
return { claudeRoot, projects };
|
|
149
177
|
});
|
|
150
178
|
app.get('/api/projects/events', async (req, reply) => {
|
|
151
179
|
writeSseHead(req, reply);
|
|
@@ -169,7 +197,7 @@ export async function registerProjectsRoutes(app) {
|
|
|
169
197
|
closed = true;
|
|
170
198
|
}
|
|
171
199
|
};
|
|
172
|
-
const initial = await enumerate(claudeRoot);
|
|
200
|
+
const initial = await attachDriftCounts(await enumerate(claudeRoot), driftSource);
|
|
173
201
|
lastSlugs = new Set(initial.map((p) => p.slug));
|
|
174
202
|
writeEvent('snapshot', { claudeRoot, projects: initial });
|
|
175
203
|
const onChange = (payload) => {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* (no `?project=`, no primary), returns a 409 with `code: 'no_project'`
|
|
14
14
|
* so the UI can show the picker without blowing up.
|
|
15
15
|
*/
|
|
16
|
+
import { productVersion } from '../product-version.js';
|
|
16
17
|
export async function registerStatusRoutes(app) {
|
|
17
18
|
app.get('/api/status', async (req, reply) => {
|
|
18
19
|
const cg = await app.activeCg(req);
|
|
@@ -28,6 +29,7 @@ export async function registerStatusRoutes(app) {
|
|
|
28
29
|
projectPath: cg.getProjectRoot ? cg.getProjectRoot() : '',
|
|
29
30
|
backend: cg.getBackend(),
|
|
30
31
|
journalMode: cg.getJournalMode(),
|
|
32
|
+
version: productVersion(),
|
|
31
33
|
nodeCount: stats.nodeCount,
|
|
32
34
|
edgeCount: stats.edgeCount,
|
|
33
35
|
fileCount: stats.fileCount,
|
|
@@ -98,6 +100,7 @@ export async function registerStatusRoutes(app) {
|
|
|
98
100
|
projectPath: cg.getProjectRoot ? cg.getProjectRoot() : '',
|
|
99
101
|
backend: cg.getBackend(),
|
|
100
102
|
journalMode: cg.getJournalMode(),
|
|
103
|
+
version: productVersion(),
|
|
101
104
|
nodeCount: stats.nodeCount,
|
|
102
105
|
edgeCount: stats.edgeCount,
|
|
103
106
|
fileCount: stats.fileCount,
|
|
@@ -36,7 +36,7 @@ export async function registerWorkflowRoutes(app) {
|
|
|
36
36
|
// the bundled tarball stages the specship core without a resolvable
|
|
37
37
|
// `@specship/specship` package on Node's module graph (offline
|
|
38
38
|
// installs hit this — see scripts/offline-install.sh).
|
|
39
|
-
// 2. **Workspace / dev** — the `file
|
|
39
|
+
// 2. **Workspace / dev** — the `file:..` workspace dep resolves the
|
|
40
40
|
// named import via Node's normal package resolution.
|
|
41
41
|
let discovery;
|
|
42
42
|
let executorMod;
|
|
@@ -99,13 +99,53 @@ export async function registerWorkflowRoutes(app) {
|
|
|
99
99
|
startPromise.catch(() => { });
|
|
100
100
|
return { runId: result.run.id, status: result.run.status };
|
|
101
101
|
});
|
|
102
|
+
// Estimated time-to-completion for in-flight runs (WORKFLOW-ETA-DOC).
|
|
103
|
+
// Guarded so a stale core build without the method degrades to "no
|
|
104
|
+
// estimate" instead of failing the runs surfaces.
|
|
105
|
+
function etaFor(cg, runId) {
|
|
106
|
+
try {
|
|
107
|
+
const fn = cg.estimateWorkflowRunEta;
|
|
108
|
+
return typeof fn === 'function' ? fn.call(cg, runId) : undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Roll a run's per-node stats (from `step_completed` events) into
|
|
116
|
+
* list-level `totalCostUsd` / `model` fields so the Runs list can render
|
|
117
|
+
* its cost/model columns without N+1 detail fetches (REQ-DESKTOP-023).
|
|
118
|
+
* Additive + best-effort: runs recorded before stats existed simply get
|
|
119
|
+
* nulls.
|
|
120
|
+
*/
|
|
121
|
+
function statsRollup(sq, runId) {
|
|
122
|
+
let totalCostUsd = null;
|
|
123
|
+
let model = null;
|
|
124
|
+
try {
|
|
125
|
+
for (const e of sq.getEventsByRun(runId, 1000)) {
|
|
126
|
+
if (e.eventType !== 'step_completed')
|
|
127
|
+
continue;
|
|
128
|
+
const stats = e.data?.stats;
|
|
129
|
+
if (typeof stats?.costUsd === 'number')
|
|
130
|
+
totalCostUsd = (totalCostUsd ?? 0) + stats.costUsd;
|
|
131
|
+
if (!model && typeof stats?.model === 'string')
|
|
132
|
+
model = stats.model;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { /* stats are decorative — never fail the list */ }
|
|
136
|
+
return { totalCostUsd, model };
|
|
137
|
+
}
|
|
102
138
|
app.get('/api/workflows/runs', async (req, reply) => {
|
|
103
139
|
const cg = await resolveCg(app, req, reply);
|
|
104
140
|
if (!cg)
|
|
105
141
|
return;
|
|
106
142
|
const { sq } = executorFor(cg);
|
|
107
143
|
const limit = Math.min(parseInt(req.query.limit ?? '50', 10) || 50, 500);
|
|
108
|
-
|
|
144
|
+
const runs = sq.getAllWorkflowRuns(limit).map((r) => ({
|
|
145
|
+
...(r.status === 'running' || r.status === 'paused' ? { ...r, eta: etaFor(cg, r.id) } : r),
|
|
146
|
+
...statsRollup(sq, r.id),
|
|
147
|
+
}));
|
|
148
|
+
return { runs };
|
|
109
149
|
});
|
|
110
150
|
app.get('/api/workflows/runs/:id', async (req, reply) => {
|
|
111
151
|
const cg = await resolveCg(app, req, reply);
|
|
@@ -116,7 +156,8 @@ export async function registerWorkflowRoutes(app) {
|
|
|
116
156
|
if (!run)
|
|
117
157
|
return reply.code(404).send({ error: 'run not found' });
|
|
118
158
|
const events = sq.getEventsByRun(req.params.id, 500);
|
|
119
|
-
|
|
159
|
+
const eta = run.status === 'running' || run.status === 'paused' ? etaFor(cg, run.id) : undefined;
|
|
160
|
+
return { run: eta === undefined ? run : { ...run, eta }, events };
|
|
120
161
|
});
|
|
121
162
|
/**
|
|
122
163
|
* Read-only artifacts for a run. The executor writes each completed node's
|