@empirical-gauzza/cli 1.0.19 → 1.0.20
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/bin/empirical.js +11 -3
- package/lib/auth.js +126 -43
- package/lib/constants.js +72 -8
- package/lib/deviceConfig.js +37 -18
- package/lib/doctor.js +484 -116
- package/lib/help.js +8 -2
- package/lib/installWizard.js +8 -2
- package/lib/installers/claude.js +110 -93
- package/lib/installers/codex.js +101 -88
- package/lib/installers/copilot.js +97 -87
- package/lib/installers/coreMemoryBlock.js +91 -0
- package/lib/installers/nativePlugin.js +147 -87
- package/lib/installers/skill-variants/SKILL.both.md +154 -0
- package/lib/installers/skill-variants/SKILL.cli.md +150 -0
- package/lib/installers/skill-variants/SKILL.mcp.md +134 -0
- package/lib/installers/skillVariants.js +59 -0
- package/lib/kitCommands.js +219 -122
- package/lib/memoryCommands.js +317 -6
- package/lib/pluginCommands.js +84 -81
- package/lib/pluginUpdateCheck.js +48 -14
- package/lib/skillPaths.js +25 -0
- package/lib/skillSync.js +48 -56
- package/package.json +15 -2
package/lib/doctor.js
CHANGED
|
@@ -1,116 +1,484 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { log, warn, ANSI, colorize, supportsColor } = require('./ui');
|
|
6
|
+
const { httpJson } = require('./http');
|
|
7
|
+
const { parseOption, parseFlag } = require('./env');
|
|
8
|
+
const { readJsonIfExists, readUtf8IfExists } = require('./fsUtils');
|
|
9
|
+
const { resolveAccessToken, resolveApiBaseUrl, buildApiAuthHeaders, inspectToken } = require('./auth');
|
|
10
|
+
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
11
|
+
const { EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_URL, MEMORY_CATEGORIES } = require('./constants');
|
|
12
|
+
const { detectInstallTargets } = require('./installers/detect');
|
|
13
|
+
const { readInstalledVersions } = require('./pluginUpdateCheck');
|
|
14
|
+
const { CORE_PLUGIN_NAME, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
|
|
15
|
+
const { getEmpiricalHooksDir, getCodexHooksScriptsDir, getCodexHomeDir } = require('./installers/hooks');
|
|
16
|
+
|
|
17
|
+
// ─── Host MCP config registration ─────────────────────────────────────────────
|
|
18
|
+
// Each host stores "is the Empirical MCP server registered" in a different shape and location.
|
|
19
|
+
// This mirrors what each installers/*.js file actually writes, so doctor can catch drift (a
|
|
20
|
+
// stale/removed entry, a URL that no longer matches EMPIRICAL_MCP_URL) without shelling out to
|
|
21
|
+
// the host CLI itself.
|
|
22
|
+
|
|
23
|
+
function checkClaudeMcpConfig() {
|
|
24
|
+
const configPath = path.join(os.homedir(), '.claude.json');
|
|
25
|
+
const config = readJsonIfExists(configPath);
|
|
26
|
+
if (!config) {
|
|
27
|
+
return { ok: false, message: `No Claude config found at ${configPath}. Run: empirical install.` };
|
|
28
|
+
}
|
|
29
|
+
const entry = config.mcpServers && config.mcpServers[EMPIRICAL_MCP_NAME];
|
|
30
|
+
if (!entry) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
message: `Empirical MCP server not registered in ${configPath}. Run: empirical install (or claude mcp add --scope user --transport http ${EMPIRICAL_MCP_NAME} ${EMPIRICAL_MCP_URL}).`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (entry.url && entry.url !== EMPIRICAL_MCP_URL) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
message: `Registered Claude MCP URL (${entry.url}) does not match the current endpoint (${EMPIRICAL_MCP_URL}). Run: empirical install to re-register.`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, message: `Registered in ${configPath} (url: ${entry.url || EMPIRICAL_MCP_URL}).` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkCopilotMcpConfig() {
|
|
46
|
+
const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
|
|
47
|
+
const config = readJsonIfExists(configPath);
|
|
48
|
+
if (!config) {
|
|
49
|
+
return { ok: false, message: `No Copilot MCP config found at ${configPath}. Run: empirical install.` };
|
|
50
|
+
}
|
|
51
|
+
const entry = config.mcpServers && config.mcpServers[EMPIRICAL_MCP_NAME];
|
|
52
|
+
if (!entry) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
message: `Empirical MCP server not registered in ${configPath}. Run: empirical install.`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (entry.url && entry.url !== EMPIRICAL_MCP_URL) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
message: `Registered Copilot MCP URL (${entry.url}) does not match the current endpoint (${EMPIRICAL_MCP_URL}). Run: empirical install to re-register.`
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, message: `Registered in ${configPath} (url: ${entry.url || EMPIRICAL_MCP_URL}).` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Codex stores MCP servers in <CODEX_HOME>/config.toml as [mcp_servers.<name>] TOML tables.
|
|
68
|
+
// There is no TOML dependency in this package, so this reads the section as text: find the
|
|
69
|
+
// [mcp_servers.empirical] header, take everything up to the next top-level "[" header, and
|
|
70
|
+
// regex out its url = "..." value. Good enough to detect presence/URL drift without a full parser.
|
|
71
|
+
function checkCodexMcpConfig() {
|
|
72
|
+
const configPath = path.join(getCodexHomeDir(), 'config.toml');
|
|
73
|
+
const raw = readUtf8IfExists(configPath);
|
|
74
|
+
if (!raw) {
|
|
75
|
+
return { ok: false, message: `No Codex config found at ${configPath}. Run: empirical install.` };
|
|
76
|
+
}
|
|
77
|
+
const headerPattern = new RegExp(`\\[mcp_servers\\.(?:"${EMPIRICAL_MCP_NAME}"|${EMPIRICAL_MCP_NAME})\\]`);
|
|
78
|
+
const headerMatch = headerPattern.exec(raw);
|
|
79
|
+
if (!headerMatch) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
message: `Empirical MCP server not registered in ${configPath}. Run: empirical install (or codex mcp add ${EMPIRICAL_MCP_NAME} --url ${EMPIRICAL_MCP_URL}).`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
86
|
+
const nextHeaderIndex = raw.indexOf('\n[', sectionStart);
|
|
87
|
+
const section = raw.slice(sectionStart, nextHeaderIndex === -1 ? raw.length : nextHeaderIndex);
|
|
88
|
+
const urlMatch = /url\s*=\s*"([^"]+)"/.exec(section);
|
|
89
|
+
const registeredUrl = urlMatch ? urlMatch[1] : null;
|
|
90
|
+
if (registeredUrl && registeredUrl !== EMPIRICAL_MCP_URL) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
message: `Registered Codex MCP URL (${registeredUrl}) does not match the current endpoint (${EMPIRICAL_MCP_URL}). Run: empirical install to re-register.`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, message: `Registered in ${configPath} (url: ${registeredUrl || EMPIRICAL_MCP_URL}).` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const MCP_CONFIG_CHECKERS = {
|
|
100
|
+
claude: checkClaudeMcpConfig,
|
|
101
|
+
codex: checkCodexMcpConfig,
|
|
102
|
+
copilot: checkCopilotMcpConfig
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ─── Hooks presence ────────────────────────────────────────────────────────────
|
|
106
|
+
// Mirrors what installers/hooks.js actually writes per host: script files on disk, plus a
|
|
107
|
+
// registration entry inside that host's own hook manifest (settings.json / hooks.json / the
|
|
108
|
+
// Copilot hooks file). A script can exist on disk but no longer be wired into the manifest (e.g.
|
|
109
|
+
// the host rewrote its config and dropped the entry), so both are checked.
|
|
110
|
+
|
|
111
|
+
function claudeHookStatus() {
|
|
112
|
+
const hooksDir = getEmpiricalHooksDir();
|
|
113
|
+
const scripts = ['claude-session-start.js', 'claude-user-prompt-submit.js', 'claude-stop.js'];
|
|
114
|
+
const missingScripts = scripts.filter((name) => !readUtf8IfExists(path.join(hooksDir, name)));
|
|
115
|
+
|
|
116
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
117
|
+
const settings = readJsonIfExists(settingsPath) || {};
|
|
118
|
+
const hookEvents = ['SessionStart', 'UserPromptSubmit', 'Stop'];
|
|
119
|
+
const registeredEvents = hookEvents.filter((eventName) => {
|
|
120
|
+
const entries = settings.hooks && settings.hooks[eventName];
|
|
121
|
+
if (!Array.isArray(entries)) return false;
|
|
122
|
+
return entries.some((entry) => Array.isArray(entry.hooks)
|
|
123
|
+
&& entry.hooks.some((h) => h && typeof h.command === 'string' && h.command.includes(hooksDir)));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { missingScripts, missingRegistrations: hookEvents.filter((e) => !registeredEvents.includes(e)), manifestPath: settingsPath };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function codexHookStatus() {
|
|
130
|
+
const hooksDir = getCodexHooksScriptsDir();
|
|
131
|
+
const scripts = ['codex-session-start.js', 'codex-user-prompt-submit.js', 'codex-stop.js'];
|
|
132
|
+
const missingScripts = scripts.filter((name) => !readUtf8IfExists(path.join(hooksDir, name)));
|
|
133
|
+
|
|
134
|
+
const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
|
|
135
|
+
const hooksFile = readJsonIfExists(hooksJsonPath) || {};
|
|
136
|
+
const hookEvents = ['SessionStart', 'UserPromptSubmit', 'Stop'];
|
|
137
|
+
const registeredEvents = hookEvents.filter((eventName) => {
|
|
138
|
+
const entries = hooksFile.hooks && hooksFile.hooks[eventName];
|
|
139
|
+
if (!Array.isArray(entries)) return false;
|
|
140
|
+
return entries.some((entry) => Array.isArray(entry.hooks)
|
|
141
|
+
&& entry.hooks.some((h) => h && typeof h.command === 'string' && h.command.includes(hooksDir)));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return { missingScripts, missingRegistrations: hookEvents.filter((e) => !registeredEvents.includes(e)), manifestPath: hooksJsonPath };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function copilotHookStatus() {
|
|
148
|
+
const hooksDir = getEmpiricalHooksDir();
|
|
149
|
+
const scripts = ['copilot-session-start.js', 'copilot-user-prompt-submit.js', 'copilot-stop.js'];
|
|
150
|
+
const missingScripts = scripts.filter((name) => !readUtf8IfExists(path.join(hooksDir, name)));
|
|
151
|
+
|
|
152
|
+
const hookFilePath = path.join(os.homedir(), '.copilot', 'hooks', 'empirical-hooks.json');
|
|
153
|
+
const hookFile = readJsonIfExists(hookFilePath) || {};
|
|
154
|
+
const hookEvents = ['sessionStart', 'userPromptSubmitted', 'agentStop'];
|
|
155
|
+
const registeredEvents = hookEvents.filter((eventName) => {
|
|
156
|
+
const entries = hookFile.hooks && hookFile.hooks[eventName];
|
|
157
|
+
if (!Array.isArray(entries)) return false;
|
|
158
|
+
return entries.some((h) => h && typeof h.command === 'string' && h.command.includes(hooksDir));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { missingScripts, missingRegistrations: hookEvents.filter((e) => !registeredEvents.includes(e)), manifestPath: hookFilePath };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const HOOK_STATUS_CHECKERS = {
|
|
165
|
+
claude: claudeHookStatus,
|
|
166
|
+
codex: codexHookStatus,
|
|
167
|
+
copilot: copilotHookStatus
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ─── MCP remote reachability ───────────────────────────────────────────────────
|
|
171
|
+
// The MCP endpoint speaks Streamable HTTP (see packages/server/src/mcp/http.ts): a POST with a
|
|
172
|
+
// JSON-RPC "initialize" body returns an SSE-framed response ("event: message\ndata: {...}\n\n"),
|
|
173
|
+
// not a bare JSON document — confirmed against the live endpoint. httpJson only guarantees a
|
|
174
|
+
// parsed body for real JSON, so a successful SSE response lands in `data.raw` and is unwrapped
|
|
175
|
+
// here by hand.
|
|
176
|
+
function extractJsonRpcResult(rawOrParsed) {
|
|
177
|
+
if (rawOrParsed && typeof rawOrParsed === 'object' && !rawOrParsed.raw) {
|
|
178
|
+
return rawOrParsed;
|
|
179
|
+
}
|
|
180
|
+
const raw = rawOrParsed && rawOrParsed.raw ? rawOrParsed.raw : '';
|
|
181
|
+
const dataLine = raw.split(/\r?\n/).find((line) => line.startsWith('data:'));
|
|
182
|
+
if (!dataLine) return null;
|
|
183
|
+
try {
|
|
184
|
+
return JSON.parse(dataLine.slice('data:'.length).trim());
|
|
185
|
+
} catch (_error) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function checkMcpReachability(token) {
|
|
191
|
+
if (!token) {
|
|
192
|
+
return { ok: false, message: 'Skipped — no token available.' };
|
|
193
|
+
}
|
|
194
|
+
const body = {
|
|
195
|
+
jsonrpc: '2.0',
|
|
196
|
+
id: 1,
|
|
197
|
+
method: 'initialize',
|
|
198
|
+
params: {
|
|
199
|
+
protocolVersion: '2024-11-05',
|
|
200
|
+
capabilities: {},
|
|
201
|
+
clientInfo: { name: 'empirical-doctor', version: '1.0.0' }
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const headers = {
|
|
205
|
+
...buildApiAuthHeaders(token),
|
|
206
|
+
Accept: 'application/json, text/event-stream'
|
|
207
|
+
};
|
|
208
|
+
const start = Date.now();
|
|
209
|
+
try {
|
|
210
|
+
const response = await httpJson(EMPIRICAL_MCP_URL, 'POST', body, headers, 10000);
|
|
211
|
+
const elapsedMs = Date.now() - start;
|
|
212
|
+
const parsed = extractJsonRpcResult(response);
|
|
213
|
+
if (!parsed) {
|
|
214
|
+
return { ok: false, message: `Reached ${EMPIRICAL_MCP_URL} (${elapsedMs}ms) but could not parse an MCP response.` };
|
|
215
|
+
}
|
|
216
|
+
if (parsed.error) {
|
|
217
|
+
return { ok: false, message: `MCP initialize returned an error: ${parsed.error.message || JSON.stringify(parsed.error)}` };
|
|
218
|
+
}
|
|
219
|
+
const serverInfo = parsed.result && parsed.result.serverInfo;
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
message: `MCP initialize round trip succeeded in ${elapsedMs}ms (server: ${serverInfo ? `${serverInfo.name}@${serverInfo.version}` : 'unknown'}).`
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const elapsedMs = Date.now() - start;
|
|
226
|
+
return { ok: false, message: `MCP request to ${EMPIRICAL_MCP_URL} failed after ${elapsedMs}ms: ${error.message}` };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── --deep: write/query/delete round trip ────────────────────────────────────
|
|
231
|
+
async function runDeepMemoryRoundTrip(token) {
|
|
232
|
+
const baseUrl = resolveApiBaseUrl();
|
|
233
|
+
const marker = `doctor-probe-${Date.now()}`;
|
|
234
|
+
const timings = {};
|
|
235
|
+
|
|
236
|
+
const recordUrl = new URL('/api/v1/memories', baseUrl).toString();
|
|
237
|
+
const category = MEMORY_CATEGORIES.includes('operations') ? 'operations' : MEMORY_CATEGORIES[0];
|
|
238
|
+
const payload = {
|
|
239
|
+
category,
|
|
240
|
+
summary: marker,
|
|
241
|
+
nodeType: 'observation',
|
|
242
|
+
tags: ['doctor-probe'],
|
|
243
|
+
data: { text: marker }
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let recordId = null;
|
|
247
|
+
const recordStart = Date.now();
|
|
248
|
+
try {
|
|
249
|
+
const response = await httpJson(recordUrl, 'POST', payload, buildApiAuthHeaders(token));
|
|
250
|
+
timings.recordMs = Date.now() - recordStart;
|
|
251
|
+
const created = (response && response.data) ? response.data : response;
|
|
252
|
+
recordId = created && (created.id || created._id) ? (created.id || created._id) : null;
|
|
253
|
+
if (!recordId) {
|
|
254
|
+
return { ok: false, message: `Record leg succeeded (${timings.recordMs}ms) but no memory id was returned in the response.` };
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
timings.recordMs = Date.now() - recordStart;
|
|
258
|
+
return { ok: false, message: `Record leg failed after ${timings.recordMs}ms: ${error.message}` };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let found = false;
|
|
262
|
+
const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
|
|
263
|
+
const queryStart = Date.now();
|
|
264
|
+
try {
|
|
265
|
+
const response = await httpJson(queryUrl, 'POST', { query: marker, topK: 5 }, buildApiAuthHeaders(token));
|
|
266
|
+
timings.queryMs = Date.now() - queryStart;
|
|
267
|
+
const results = (response && response.data && Array.isArray(response.data.results))
|
|
268
|
+
? response.data.results
|
|
269
|
+
: (Array.isArray(response) ? response : []);
|
|
270
|
+
found = results.some((r) => r && r.id === recordId);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
timings.queryMs = Date.now() - queryStart;
|
|
273
|
+
// Fall through to best-effort delete below so a query failure doesn't leave the probe memory
|
|
274
|
+
// stranded — deletion below still runs regardless.
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let deleted = false;
|
|
278
|
+
let deleteError = null;
|
|
279
|
+
const deleteUrl = new URL(`/api/v1/memories/${encodeURIComponent(recordId)}`, baseUrl).toString();
|
|
280
|
+
const deleteStart = Date.now();
|
|
281
|
+
try {
|
|
282
|
+
await httpJson(deleteUrl, 'DELETE', null, buildApiAuthHeaders(token));
|
|
283
|
+
timings.deleteMs = Date.now() - deleteStart;
|
|
284
|
+
deleted = true;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
timings.deleteMs = Date.now() - deleteStart;
|
|
287
|
+
deleteError = error.message;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const timingSummary = `record ${timings.recordMs}ms, query ${timings.queryMs}ms, delete ${timings.deleteMs}ms`;
|
|
291
|
+
if (!deleted) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
message: `Round trip left a probe memory behind (id ${recordId}) — delete failed: ${deleteError}. ${timingSummary}.`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (!found) {
|
|
298
|
+
return {
|
|
299
|
+
ok: 'warn',
|
|
300
|
+
message: `Wrote and deleted probe memory ${marker} cleanly, but the query leg did not find it (indexing lag or search-side issue). ${timingSummary}.`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, message: `Wrote, queried, and deleted probe memory ${marker} cleanly. ${timingSummary}.` };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function runDoctor(args) {
|
|
307
|
+
return withCliAnalyticsContext({ command: 'doctor' }, async () => {
|
|
308
|
+
const outputJson = parseOption(args, '--output', '') === 'json';
|
|
309
|
+
const deep = parseFlag(args, '--deep');
|
|
310
|
+
const checks = [];
|
|
311
|
+
let overallFail = false;
|
|
312
|
+
let overallWarn = false;
|
|
313
|
+
|
|
314
|
+
function addCheck(name, status, message) {
|
|
315
|
+
checks.push({ name, status, message });
|
|
316
|
+
if (status === 'fail') overallFail = true;
|
|
317
|
+
if (status === 'warn') overallWarn = true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const token = resolveAccessToken();
|
|
321
|
+
if (!token) {
|
|
322
|
+
addCheck('token_presence', 'fail', 'No access token found. Run: empirical auth login, or configure headless credentials with empirical oauth bootstrap headless.');
|
|
323
|
+
} else {
|
|
324
|
+
addCheck('token_presence', 'ok', 'Access token found.');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Token type/expiry decoding is shared with `auth status`/`whoami` via auth.js's
|
|
328
|
+
// inspectToken, so the two commands can never silently disagree on what "expired" means.
|
|
329
|
+
const tokenInfo = inspectToken(token);
|
|
330
|
+
if (token) {
|
|
331
|
+
if (tokenInfo.tokenType === 'jwt') {
|
|
332
|
+
addCheck('token_type', 'ok', 'Token is a JWT (3-part).');
|
|
333
|
+
} else {
|
|
334
|
+
addCheck('token_type', 'ok', 'Token is an API key (long alphanumeric).');
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
addCheck('token_type', 'fail', 'Cannot determine token type — no token found.');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (token && tokenInfo.tokenType === 'jwt') {
|
|
341
|
+
if (tokenInfo.expiresAt === null) {
|
|
342
|
+
addCheck('token_expiry', 'warn', 'JWT has no `exp` claim or could not be decoded — expiry unknown.');
|
|
343
|
+
} else if (tokenInfo.expired) {
|
|
344
|
+
addCheck('token_expiry', 'fail', `Token expired at ${tokenInfo.expiresAt}.`);
|
|
345
|
+
} else {
|
|
346
|
+
const remaining = Math.round((new Date(tokenInfo.expiresAt).getTime() - Date.now()) / 1000);
|
|
347
|
+
addCheck('token_expiry', 'ok', `Token expires in ${remaining}s (at ${tokenInfo.expiresAt}).`);
|
|
348
|
+
}
|
|
349
|
+
} else if (token && tokenInfo.tokenType === 'apikey') {
|
|
350
|
+
addCheck('token_expiry', 'ok', 'API key — no expiry.');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let userData = null;
|
|
354
|
+
if (token) {
|
|
355
|
+
const baseUrl = resolveApiBaseUrl();
|
|
356
|
+
const meUrl = new URL('/api/v1/users/me', baseUrl).toString();
|
|
357
|
+
try {
|
|
358
|
+
const response = await httpJson(meUrl, 'GET', null, buildApiAuthHeaders(token));
|
|
359
|
+
userData = (response && response.data) ? response.data : response;
|
|
360
|
+
addCheck('api_connectivity', 'ok', `Connected. User: ${userData.email || userData.userId || 'unknown'}.`);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
addCheck('api_connectivity', 'fail', `API request failed: ${error.message}`);
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
addCheck('api_connectivity', 'fail', 'Skipped — no token available.');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (userData) {
|
|
369
|
+
const wsId = userData.workspaceId || userData.workspaceName || null;
|
|
370
|
+
if (wsId) {
|
|
371
|
+
addCheck('workspace', 'ok', `Workspace ID: ${wsId}.`);
|
|
372
|
+
} else {
|
|
373
|
+
addCheck('workspace', 'warn', 'No active workspace — using default brain.');
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
addCheck('workspace', 'fail', 'Skipped — API connectivity check failed.');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── MCP wiring diagnostics (C-007) ─────────────────────────────────────
|
|
380
|
+
const detectedTargets = detectInstallTargets();
|
|
381
|
+
if (detectedTargets.length === 0) {
|
|
382
|
+
addCheck('mcp_hosts_detected', 'warn', 'No supported host CLI detected (codex/claude/copilot). Nothing to check for MCP wiring.');
|
|
383
|
+
} else {
|
|
384
|
+
addCheck('mcp_hosts_detected', 'ok', `Detected: ${detectedTargets.map((t) => t.label).join(', ')}.`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (const target of detectedTargets) {
|
|
388
|
+
const checker = MCP_CONFIG_CHECKERS[target.id];
|
|
389
|
+
if (!checker) continue;
|
|
390
|
+
let result;
|
|
391
|
+
try {
|
|
392
|
+
result = checker();
|
|
393
|
+
} catch (error) {
|
|
394
|
+
result = { ok: false, message: `Could not inspect ${target.label} MCP config: ${error.message}` };
|
|
395
|
+
}
|
|
396
|
+
addCheck(`mcp_config_${target.id}`, result.ok ? 'ok' : 'fail', `${target.label}: ${result.message}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const mcpReachability = await checkMcpReachability(token);
|
|
400
|
+
addCheck('mcp_reachability', mcpReachability.ok ? 'ok' : 'fail', mcpReachability.message);
|
|
401
|
+
|
|
402
|
+
const pluginNames = [CORE_PLUGIN_NAME, ...OPTIONAL_PLUGIN_NAMES];
|
|
403
|
+
for (const target of detectedTargets) {
|
|
404
|
+
let versions;
|
|
405
|
+
try {
|
|
406
|
+
versions = readInstalledVersions(target.id, pluginNames);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
versions = null;
|
|
409
|
+
}
|
|
410
|
+
if (!versions) {
|
|
411
|
+
addCheck(
|
|
412
|
+
`plugin_${target.id}`,
|
|
413
|
+
'warn',
|
|
414
|
+
`${target.label}: could not read plugin list (CLI not on PATH or "${target.id} plugin list" failed). Run: empirical plugin install ${target.id}.`
|
|
415
|
+
);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const coreVersion = versions[CORE_PLUGIN_NAME];
|
|
419
|
+
if (!coreVersion) {
|
|
420
|
+
addCheck(`plugin_${target.id}`, 'fail', `${target.label}: empirical-memory plugin not installed. Run: empirical plugin install ${target.id}.`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const optionalInstalled = OPTIONAL_PLUGIN_NAMES.filter((name) => versions[name]);
|
|
424
|
+
const optionalSummary = optionalInstalled.length ? `; optional: ${optionalInstalled.map((name) => `${name}@${versions[name]}`).join(', ')}` : '; no optional plugins installed';
|
|
425
|
+
addCheck(`plugin_${target.id}`, 'ok', `${target.label}: empirical-memory@${coreVersion}${optionalSummary}.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
for (const target of detectedTargets) {
|
|
429
|
+
const statusFn = HOOK_STATUS_CHECKERS[target.id];
|
|
430
|
+
if (!statusFn) continue;
|
|
431
|
+
let status;
|
|
432
|
+
try {
|
|
433
|
+
status = statusFn();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
addCheck(`hooks_${target.id}`, 'fail', `${target.label}: could not inspect hooks: ${error.message}`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (status.missingScripts.length === 0 && status.missingRegistrations.length === 0) {
|
|
439
|
+
addCheck(`hooks_${target.id}`, 'ok', `${target.label}: SessionStart/UserPromptSubmit/Stop hook scripts present and registered in ${status.manifestPath}.`);
|
|
440
|
+
} else {
|
|
441
|
+
const parts = [];
|
|
442
|
+
if (status.missingScripts.length) parts.push(`missing script file(s): ${status.missingScripts.join(', ')}`);
|
|
443
|
+
if (status.missingRegistrations.length) parts.push(`not registered in ${status.manifestPath}: ${status.missingRegistrations.join(', ')}`);
|
|
444
|
+
addCheck(`hooks_${target.id}`, 'fail', `${target.label}: ${parts.join('; ')}. Run: empirical install.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── --deep: live write/query/delete round trip ─────────────────────────
|
|
449
|
+
if (deep) {
|
|
450
|
+
if (!token) {
|
|
451
|
+
addCheck('deep_write_read_delete', 'fail', 'Skipped — no token available.');
|
|
452
|
+
} else {
|
|
453
|
+
const result = await runDeepMemoryRoundTrip(token);
|
|
454
|
+
const status = result.ok === true ? 'ok' : (result.ok === 'warn' ? 'warn' : 'fail');
|
|
455
|
+
addCheck('deep_write_read_delete', status, result.message);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const overall = overallFail ? 'fail' : (overallWarn ? 'warn' : 'ok');
|
|
460
|
+
|
|
461
|
+
if (outputJson) {
|
|
462
|
+
log(JSON.stringify({ checks, overall }));
|
|
463
|
+
} else {
|
|
464
|
+
const statusLabel = (status) => {
|
|
465
|
+
if (status === 'ok') return colorize(ANSI.green, '[OK] ');
|
|
466
|
+
if (status === 'warn') return colorize(ANSI.yellow, '[WARN]');
|
|
467
|
+
return colorize(ANSI.red, '[FAIL]');
|
|
468
|
+
};
|
|
469
|
+
for (const check of checks) {
|
|
470
|
+
log(`${statusLabel(check.status)} ${check.name.padEnd(20)} ${check.message}`);
|
|
471
|
+
}
|
|
472
|
+
log('');
|
|
473
|
+
log(`Overall: ${overall.toUpperCase()}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (overallFail) {
|
|
477
|
+
process.exitCode = 1;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
module.exports = {
|
|
483
|
+
runDoctor
|
|
484
|
+
};
|
package/lib/help.js
CHANGED
|
@@ -19,6 +19,7 @@ function showHelp() {
|
|
|
19
19
|
log(' empirical policy set --json <policy>');
|
|
20
20
|
log(' empirical auth login Interactive browser login');
|
|
21
21
|
log(' empirical auth logout Clear the locally stored session');
|
|
22
|
+
log(' empirical auth status Print user, token type, expiry, workspace as JSON (alias: whoami)');
|
|
22
23
|
log(' empirical oauth bootstrap headless [--print-access-token]');
|
|
23
24
|
log(' First-time token setup for CI/headless/API-key use');
|
|
24
25
|
log(' empirical oauth bootstrap headless --write-env [--env-output <path>]');
|
|
@@ -74,6 +75,11 @@ function showHelp() {
|
|
|
74
75
|
log(' empirical memory duplicates List duplicate candidates for a memory');
|
|
75
76
|
log(' empirical memory merge Merge a duplicate memory into a canonical one');
|
|
76
77
|
log(' empirical memory export Export memories as portable JSON [--scope <personal|workspace>] [--out <path>]');
|
|
78
|
+
log(' empirical memory import <file> Import a versioned memory export [--dry-run]');
|
|
79
|
+
log(' empirical memory stats Counts by category/tag, total, oldest/newest [--format table]');
|
|
80
|
+
log(' empirical memory compact Dry-run preview of merge/link candidates [--apply] [--category <cat>]');
|
|
81
|
+
log(' empirical memory delete --tag <t>|--category <c>|--all --confirm');
|
|
82
|
+
log(' Bulk soft-delete by tag/category/all (dry-run without --confirm)');
|
|
77
83
|
log(' empirical kit create <name> Create a new Kit [--workspace <workspaceId>]');
|
|
78
84
|
log(' empirical kit add-source <kit> <git-url>');
|
|
79
85
|
log(' Add a git Source to a Kit [--ref <ref>] [--path <subpath>]');
|
|
@@ -92,9 +98,9 @@ function showHelp() {
|
|
|
92
98
|
log(' Push a local SKILL.md as an authored Skill [--kit <kitId>]');
|
|
93
99
|
log(' Wrote or edited a SKILL.md? Push it so it follows you');
|
|
94
100
|
log(' across machines/projects instead of staying local.');
|
|
95
|
-
log(' empirical skill history <authoredSkillId>');
|
|
101
|
+
log(' empirical skill history <authoredSkillId|name> [--kit <kitId>]');
|
|
96
102
|
log(' Show version history for an authored Skill');
|
|
97
|
-
log(' empirical skill rollback <authoredSkillId> --to-version <n>');
|
|
103
|
+
log(' empirical skill rollback <authoredSkillId|name> --to-version <n> [--kit <kitId>]');
|
|
98
104
|
log(' Roll an authored Skill back to a prior version');
|
|
99
105
|
log(' empirical device list List registered devices and active Kits');
|
|
100
106
|
log(' empirical device show <deviceId> Show effective Skills for a device');
|