@empirical-gauzza/cli 1.0.30 → 1.0.32
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/deviceConfig.js +27 -0
- package/lib/doctor.js +64 -4
- package/lib/installWizard.js +8 -2
- package/lib/installers/claude.js +2 -9
- package/lib/installers/codex.js +2 -9
- package/lib/installers/copilot.js +2 -8
- package/lib/installers/coreMemoryBlock.js +42 -1
- package/lib/installers/detect.js +22 -6
- package/lib/installers/hooks.js +12 -6
- package/lib/installers/nativePlugin.js +116 -15
- package/lib/pluginCommands.js +11 -0
- package/package.json +1 -1
package/lib/deviceConfig.js
CHANGED
|
@@ -41,6 +41,31 @@ function writeIntegrationMode(integrationMode) {
|
|
|
41
41
|
writeDeviceConfig(config);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Hosts (codex / copilot / claude) that Empirical has actually been installed for on this
|
|
45
|
+
// machine, recorded by `empirical install` and `empirical plugin install`. `doctor` reads this
|
|
46
|
+
// so a host that merely exists on the machine (a ~/.claude folder left by another tool, or a CLI
|
|
47
|
+
// the user deliberately skipped in the installer) is reported as skipped, not as broken.
|
|
48
|
+
// Returns null when nothing was ever recorded (installs that predate this field), so callers
|
|
49
|
+
// can tell "unknown" apart from "none".
|
|
50
|
+
const KNOWN_HOST_IDS = ['codex', 'copilot', 'claude'];
|
|
51
|
+
|
|
52
|
+
function readInstalledHosts() {
|
|
53
|
+
const config = readDeviceConfig();
|
|
54
|
+
if (!Array.isArray(config.installedHosts)) return null;
|
|
55
|
+
return config.installedHosts.filter((id) => KNOWN_HOST_IDS.includes(id));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addInstalledHosts(hostIds) {
|
|
59
|
+
const config = readDeviceConfig();
|
|
60
|
+
const existing = Array.isArray(config.installedHosts) ? config.installedHosts : [];
|
|
61
|
+
const merged = new Set(existing.filter((id) => KNOWN_HOST_IDS.includes(id)));
|
|
62
|
+
for (const id of hostIds || []) {
|
|
63
|
+
if (KNOWN_HOST_IDS.includes(id)) merged.add(id);
|
|
64
|
+
}
|
|
65
|
+
config.installedHosts = KNOWN_HOST_IDS.filter((id) => merged.has(id));
|
|
66
|
+
writeDeviceConfig(config);
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
function readPolicyCache() {
|
|
45
70
|
const config = readDeviceConfig();
|
|
46
71
|
return config.empiricalPolicy || null;
|
|
@@ -101,6 +126,8 @@ module.exports = {
|
|
|
101
126
|
writeDeviceConfig,
|
|
102
127
|
readIntegrationMode,
|
|
103
128
|
writeIntegrationMode,
|
|
129
|
+
readInstalledHosts,
|
|
130
|
+
addInstalledHosts,
|
|
104
131
|
readPolicyCache,
|
|
105
132
|
writePolicyCache,
|
|
106
133
|
ensureDeviceId,
|
package/lib/doctor.js
CHANGED
|
@@ -13,6 +13,39 @@ const { detectInstallTargets } = require('./installers/detect');
|
|
|
13
13
|
const { readInstalledVersions } = require('./pluginUpdateCheck');
|
|
14
14
|
const { CORE_PLUGIN_NAME, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
|
|
15
15
|
const { getEmpiricalHooksDir, getCodexHooksScriptsDir, getCodexHomeDir } = require('./installers/hooks');
|
|
16
|
+
const { readIntegrationMode, readInstalledHosts } = require('./deviceConfig');
|
|
17
|
+
|
|
18
|
+
// ─── Which detected hosts doctor should grade ──────────────────────────────────
|
|
19
|
+
// Doctor output is read by agents, and a FAIL line with a "Run: ..." remedy is, to an agent, an
|
|
20
|
+
// instruction. So a host the user never set Empirical up for must not be graded as broken:
|
|
21
|
+
// - If the installer recorded which hosts it installed for, grade exactly those.
|
|
22
|
+
// - Otherwise (installs that predate the record), grade only hosts whose binary is on PATH; a
|
|
23
|
+
// bare dotfolder (e.g. ~/.claude left behind with no `claude` command) is nothing to check.
|
|
24
|
+
// Skipped hosts still get one informational line so the user can see why they were left out.
|
|
25
|
+
function partitionHostsToCheck(detectedTargets, installedHosts) {
|
|
26
|
+
const checked = [];
|
|
27
|
+
const skipped = [];
|
|
28
|
+
for (const target of detectedTargets) {
|
|
29
|
+
if (installedHosts !== null) {
|
|
30
|
+
if (installedHosts.includes(target.id)) {
|
|
31
|
+
checked.push(target);
|
|
32
|
+
} else {
|
|
33
|
+
const evidence = target.onPath
|
|
34
|
+
? `${target.id} is on PATH`
|
|
35
|
+
: `only its ${target.configDir} folder exists; ${target.id} is not on PATH`;
|
|
36
|
+
skipped.push({ target, reason: `not set up for Empirical (${evidence}). To add it: empirical install.` });
|
|
37
|
+
}
|
|
38
|
+
} else if (target.onPath) {
|
|
39
|
+
checked.push(target);
|
|
40
|
+
} else {
|
|
41
|
+
skipped.push({
|
|
42
|
+
target,
|
|
43
|
+
reason: `only its ${target.configDir} folder exists; ${target.id} is not on PATH, so there is nothing to check. To set it up later: empirical install.`
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { checked, skipped };
|
|
48
|
+
}
|
|
16
49
|
|
|
17
50
|
// ─── Host MCP config registration ─────────────────────────────────────────────
|
|
18
51
|
// Each host stores "is the Empirical MCP server registered" in a different shape and location.
|
|
@@ -377,11 +410,28 @@ async function runDoctor(args) {
|
|
|
377
410
|
}
|
|
378
411
|
|
|
379
412
|
// ─── MCP wiring diagnostics (C-007) ─────────────────────────────────────
|
|
380
|
-
const
|
|
381
|
-
|
|
413
|
+
const allDetected = detectInstallTargets();
|
|
414
|
+
let installedHosts = null;
|
|
415
|
+
let integrationMode = 1;
|
|
416
|
+
try {
|
|
417
|
+
installedHosts = readInstalledHosts();
|
|
418
|
+
integrationMode = readIntegrationMode();
|
|
419
|
+
} catch (_error) {
|
|
420
|
+
installedHosts = null;
|
|
421
|
+
integrationMode = 1;
|
|
422
|
+
}
|
|
423
|
+
const { checked: detectedTargets, skipped: skippedTargets } = partitionHostsToCheck(allDetected, installedHosts);
|
|
424
|
+
|
|
425
|
+
if (allDetected.length === 0) {
|
|
382
426
|
addCheck('mcp_hosts_detected', 'warn', 'No supported host CLI detected (codex/claude/copilot). Nothing to check for MCP wiring.');
|
|
427
|
+
} else if (detectedTargets.length === 0) {
|
|
428
|
+
addCheck('mcp_hosts_detected', 'warn', `Detected: ${allDetected.map((t) => t.label).join(', ')}, but none is set up for Empirical. Run: empirical install.`);
|
|
383
429
|
} else {
|
|
384
|
-
|
|
430
|
+
const modeLabel = integrationMode === 2 ? 'CLI-direct' : integrationMode === 3 ? 'MCP + CLI' : 'MCP';
|
|
431
|
+
addCheck('mcp_hosts_detected', 'ok', `Checking: ${detectedTargets.map((t) => t.label).join(', ')} (${modeLabel} mode).`);
|
|
432
|
+
}
|
|
433
|
+
for (const { target, reason } of skippedTargets) {
|
|
434
|
+
addCheck(`host_${target.id}`, 'skip', `${target.label}: ${reason}`);
|
|
385
435
|
}
|
|
386
436
|
|
|
387
437
|
for (const target of detectedTargets) {
|
|
@@ -393,7 +443,16 @@ async function runDoctor(args) {
|
|
|
393
443
|
} catch (error) {
|
|
394
444
|
result = { ok: false, message: `Could not inspect ${target.label} MCP config: ${error.message}` };
|
|
395
445
|
}
|
|
396
|
-
|
|
446
|
+
if (integrationMode === 2) {
|
|
447
|
+
// CLI-direct mode: agents call the `empirical` CLI, so an unregistered MCP server is the
|
|
448
|
+
// expected state, not a defect. Report whatever is there without failing on absence.
|
|
449
|
+
const message = result.ok
|
|
450
|
+
? `${result.message} (CLI-direct mode; MCP registration is optional.)`
|
|
451
|
+
: 'CLI-direct mode: MCP server not registered, and none is expected. Agents use the empirical CLI.';
|
|
452
|
+
addCheck(`mcp_config_${target.id}`, 'ok', `${target.label}: ${message}`);
|
|
453
|
+
} else {
|
|
454
|
+
addCheck(`mcp_config_${target.id}`, result.ok ? 'ok' : 'fail', `${target.label}: ${result.message}`);
|
|
455
|
+
}
|
|
397
456
|
}
|
|
398
457
|
|
|
399
458
|
const mcpReachability = await checkMcpReachability(token);
|
|
@@ -464,6 +523,7 @@ async function runDoctor(args) {
|
|
|
464
523
|
const statusLabel = (status) => {
|
|
465
524
|
if (status === 'ok') return colorize(ANSI.green, '[OK] ');
|
|
466
525
|
if (status === 'warn') return colorize(ANSI.yellow, '[WARN]');
|
|
526
|
+
if (status === 'skip') return colorize(ANSI.dim, '[SKIP]');
|
|
467
527
|
return colorize(ANSI.red, '[FAIL]');
|
|
468
528
|
};
|
|
469
529
|
for (const check of checks) {
|
package/lib/installWizard.js
CHANGED
|
@@ -23,8 +23,9 @@ const { detectInstallTargets } = require('./installers/detect');
|
|
|
23
23
|
const { installClaudeHooks, installCodexHooks, installCopilotHooks } = require('./installers/hooks');
|
|
24
24
|
const { runCodex } = require('./installers/codex');
|
|
25
25
|
const { installNativePlugin, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
|
|
26
|
+
const { writeCoreMemoryGuidance } = require('./installers/coreMemoryBlock');
|
|
26
27
|
const { policyFromInstallerOptions, savePolicy: persistPolicy } = require('./policyCommands');
|
|
27
|
-
const { writeIntegrationMode } = require('./deviceConfig');
|
|
28
|
+
const { writeIntegrationMode, addInstalledHosts } = require('./deviceConfig');
|
|
28
29
|
|
|
29
30
|
function pluginNamesForInstallerOptions(options = {}) {
|
|
30
31
|
const names = ['empirical-memory'];
|
|
@@ -234,10 +235,13 @@ async function runInstallFlow() {
|
|
|
234
235
|
log(uiHeader(`Step 2/2: Installing for ${target.label}...`));
|
|
235
236
|
try {
|
|
236
237
|
if (integrationMode === 2) {
|
|
237
|
-
// CLI-direct: skip MCP registration
|
|
238
|
+
// CLI-direct: skip MCP registration only. The skill, the hooks AND the global
|
|
239
|
+
// instructions block are all still required -- a real Copilot install that skipped the
|
|
240
|
+
// instructions file here left the agent ignoring Empirical for a whole session.
|
|
238
241
|
const pluginNames = pluginNamesForInstallerOptions(promptOptions);
|
|
239
242
|
installNativePlugin(target.id, pluginNames, integrationMode);
|
|
240
243
|
log(` - ${target.label} native plugins installed: ${pluginNames.join(', ')}.`);
|
|
244
|
+
writeCoreMemoryGuidance(target.id, pluginNames);
|
|
241
245
|
if (target.id === 'claude') {
|
|
242
246
|
try {
|
|
243
247
|
installClaudeHooks(pluginNames);
|
|
@@ -260,6 +264,7 @@ async function runInstallFlow() {
|
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
log(uiSuccess(`Done: ${target.label} (CLI-direct mode)`));
|
|
267
|
+
addInstalledHosts([target.id]);
|
|
263
268
|
} else {
|
|
264
269
|
// Mode 1 or 3: run MCP install normally
|
|
265
270
|
const pluginNames = pluginNamesForInstallerOptions(promptOptions);
|
|
@@ -269,6 +274,7 @@ async function runInstallFlow() {
|
|
|
269
274
|
integrationMode
|
|
270
275
|
});
|
|
271
276
|
log(uiSuccess(`Done: ${target.label}`));
|
|
277
|
+
addInstalledHosts([target.id]);
|
|
272
278
|
if (target.id === 'codex') {
|
|
273
279
|
codexInstalled = true;
|
|
274
280
|
codexNeedsPostLogin = Boolean(installResult?.needsPostLogin);
|
package/lib/installers/claude.js
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
|
|
12
12
|
const { installClaudeHooks } = require('./hooks');
|
|
13
13
|
const { installNativePlugin, CORE_PLUGIN_NAME } = require('./nativePlugin');
|
|
14
|
-
const {
|
|
14
|
+
const { writeCoreMemoryGuidance } = require('./coreMemoryBlock');
|
|
15
15
|
|
|
16
16
|
function runClaude(args, options = {}) {
|
|
17
17
|
return runResolvedCommand('claude', args, options);
|
|
@@ -30,14 +30,7 @@ function installClaude(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEF
|
|
|
30
30
|
// writes the same kind of identity-framed statement into the one file Claude reads every
|
|
31
31
|
// session unconditionally, inside a clearly delimited block so any of the user's own content
|
|
32
32
|
// in CLAUDE.md is left untouched.
|
|
33
|
-
|
|
34
|
-
const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
35
|
-
const block = buildCoreMemoryBlock({ pluginNames: installOptions.pluginNames || [CORE_PLUGIN_NAME] });
|
|
36
|
-
upsertCoreMemoryBlock(claudeMdPath, block);
|
|
37
|
-
log(` - Wrote Empirical core memory guidance to ${claudeMdPath}.`);
|
|
38
|
-
} catch (error) {
|
|
39
|
-
warn(` - Could not write Empirical core memory guidance to CLAUDE.md: ${error.message}`);
|
|
40
|
-
}
|
|
33
|
+
writeCoreMemoryGuidance('claude', installOptions.pluginNames || [CORE_PLUGIN_NAME]);
|
|
41
34
|
|
|
42
35
|
// installNativePlugin throws if the plugin install/update genuinely fails (see
|
|
43
36
|
// assertPluginInstallSucceeded in nativePlugin.js) - hooks are only installed AFTER this
|
package/lib/installers/codex.js
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
|
|
12
12
|
const { installCodexHooks } = require('./hooks');
|
|
13
13
|
const { installNativePlugin, CORE_PLUGIN_NAME } = require('./nativePlugin');
|
|
14
|
-
const {
|
|
14
|
+
const { writeCoreMemoryGuidance } = require('./coreMemoryBlock');
|
|
15
15
|
|
|
16
16
|
function runCodex(args, options = {}) {
|
|
17
17
|
return runResolvedCommand('codex', args, options);
|
|
@@ -26,14 +26,7 @@ function installCodex(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFA
|
|
|
26
26
|
// 2026-08-24: see claude.js's identical block for why this now writes AGENTS.md instead of
|
|
27
27
|
// leaving it untouched -- hooks + an installable skill alone were not enough against a
|
|
28
28
|
// competing identity-framed built-in memory feature in a real session.
|
|
29
|
-
|
|
30
|
-
const agentsMdPath = path.join(os.homedir(), '.codex', 'AGENTS.md');
|
|
31
|
-
const block = buildCoreMemoryBlock({ pluginNames: installOptions.pluginNames || [CORE_PLUGIN_NAME] });
|
|
32
|
-
upsertCoreMemoryBlock(agentsMdPath, block);
|
|
33
|
-
log(` - Wrote Empirical core memory guidance to ${agentsMdPath}.`);
|
|
34
|
-
} catch (error) {
|
|
35
|
-
warn(` - Could not write Empirical core memory guidance to AGENTS.md: ${error.message}`);
|
|
36
|
-
}
|
|
29
|
+
writeCoreMemoryGuidance('codex', installOptions.pluginNames || [CORE_PLUGIN_NAME]);
|
|
37
30
|
|
|
38
31
|
// installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
|
|
39
32
|
// nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
|
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
|
|
13
13
|
const { installCopilotHooks } = require('./hooks');
|
|
14
14
|
const { installNativePlugin, CORE_PLUGIN_NAME } = require('./nativePlugin');
|
|
15
|
-
const {
|
|
15
|
+
const { writeCoreMemoryGuidance } = require('./coreMemoryBlock');
|
|
16
16
|
|
|
17
17
|
function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
|
|
18
18
|
const home = os.homedir();
|
|
@@ -65,13 +65,7 @@ function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DE
|
|
|
65
65
|
// 2026-08-24: see claude.js's identical block for why this now writes copilot-instructions.md
|
|
66
66
|
// instead of leaving it untouched -- hooks + an installable skill alone were not enough
|
|
67
67
|
// against a competing identity-framed built-in memory feature in a real session.
|
|
68
|
-
|
|
69
|
-
const block = buildCoreMemoryBlock({ pluginNames: installOptions.pluginNames || [CORE_PLUGIN_NAME] });
|
|
70
|
-
upsertCoreMemoryBlock(instructionsPath, block);
|
|
71
|
-
log(` - Wrote Empirical core memory guidance to ${instructionsPath}.`);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
warn(` - Could not write Empirical core memory guidance to copilot-instructions.md: ${error.message}`);
|
|
74
|
-
}
|
|
68
|
+
writeCoreMemoryGuidance('copilot', installOptions.pluginNames || [CORE_PLUGIN_NAME]);
|
|
75
69
|
|
|
76
70
|
// installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
|
|
77
71
|
// nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { readUtf8IfExists, writeFile, ensureDir } = require('../fsUtils');
|
|
4
|
+
const { log, warn } = require('../ui');
|
|
5
|
+
const os = require('os');
|
|
4
6
|
const path = require('path');
|
|
5
7
|
const { CORE_PLUGIN_NAME, OPTIONAL_PLUGIN_NAMES } = require('./nativePlugin');
|
|
6
8
|
|
|
@@ -88,9 +90,48 @@ function upsertCoreMemoryBlock(filePath, block) {
|
|
|
88
90
|
writeFile(filePath, next);
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
// The one instructions file each host reads unconditionally every session. Kept here, next to
|
|
94
|
+
// the block it receives, so every install path (full installer in any mode, `plugin install`)
|
|
95
|
+
// writes to the same place instead of each path re-deriving it.
|
|
96
|
+
const INSTRUCTIONS_FILE_BY_HOST = {
|
|
97
|
+
claude: ['.claude', 'CLAUDE.md'],
|
|
98
|
+
codex: ['.codex', 'AGENTS.md'],
|
|
99
|
+
copilot: ['.copilot', 'copilot-instructions.md']
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function instructionsFilePathForHost(hostId) {
|
|
103
|
+
const segments = INSTRUCTIONS_FILE_BY_HOST[hostId];
|
|
104
|
+
if (!segments) throw new Error(`No global instructions file is known for host "${hostId}".`);
|
|
105
|
+
return path.join(os.homedir(), ...segments);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Writes (or refreshes) the core memory block in the host's global instructions file. Every
|
|
110
|
+
* install path must call this, whatever integration mode the user picked: a real CLI-direct
|
|
111
|
+
* Copilot install (2026-09-17) installed the skill and hooks but skipped this file, and the
|
|
112
|
+
* agent ignored Empirical for the whole session exactly as the 2026-08-24 incident predicted.
|
|
113
|
+
* Never throws; a failure here is logged and must not fail the install that already succeeded.
|
|
114
|
+
* Returns the path written, or null on failure.
|
|
115
|
+
*/
|
|
116
|
+
function writeCoreMemoryGuidance(hostId, pluginNames = [CORE_PLUGIN_NAME]) {
|
|
117
|
+
let filePath = null;
|
|
118
|
+
try {
|
|
119
|
+
filePath = instructionsFilePathForHost(hostId);
|
|
120
|
+
const block = buildCoreMemoryBlock({ pluginNames: pluginNames && pluginNames.length ? pluginNames : [CORE_PLUGIN_NAME] });
|
|
121
|
+
upsertCoreMemoryBlock(filePath, block);
|
|
122
|
+
log(` - Wrote Empirical core memory guidance to ${filePath}.`);
|
|
123
|
+
return filePath;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
warn(` - Could not write Empirical core memory guidance for ${hostId}${filePath ? ` (${filePath})` : ''}: ${error.message}`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
91
130
|
module.exports = {
|
|
92
131
|
BEGIN_MARKER,
|
|
93
132
|
END_MARKER,
|
|
94
133
|
buildCoreMemoryBlock,
|
|
95
|
-
upsertCoreMemoryBlock
|
|
134
|
+
upsertCoreMemoryBlock,
|
|
135
|
+
instructionsFilePathForHost,
|
|
136
|
+
writeCoreMemoryGuidance
|
|
96
137
|
};
|
package/lib/installers/detect.js
CHANGED
|
@@ -8,33 +8,49 @@ const { installCodex } = require('./codex');
|
|
|
8
8
|
const { installCopilot } = require('./copilot');
|
|
9
9
|
const { installClaude } = require('./claude');
|
|
10
10
|
|
|
11
|
+
// A host counts as detected when its binary is on PATH OR its home dotfolder exists. The
|
|
12
|
+
// dotfolder alone is a weak signal (another tool, an uninstalled CLI, or a desktop app can leave
|
|
13
|
+
// one behind), so each target also carries `onPath` and `configDirExists` separately; doctor uses
|
|
14
|
+
// them to say "found only a folder, nothing to check" instead of failing the host.
|
|
11
15
|
function detectInstallTargets() {
|
|
12
16
|
const home = os.homedir();
|
|
13
17
|
const targets = [];
|
|
14
18
|
|
|
15
|
-
const
|
|
16
|
-
|
|
19
|
+
const codexOnPath = commandExists('codex');
|
|
20
|
+
const codexDirExists = fs.existsSync(path.join(home, '.codex'));
|
|
21
|
+
if (codexOnPath || codexDirExists) {
|
|
17
22
|
targets.push({
|
|
18
23
|
id: 'codex',
|
|
19
24
|
label: 'Codex CLI',
|
|
25
|
+
onPath: codexOnPath,
|
|
26
|
+
configDirExists: codexDirExists,
|
|
27
|
+
configDir: path.join(home, '.codex'),
|
|
20
28
|
install: installCodex
|
|
21
29
|
});
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
const
|
|
25
|
-
|
|
32
|
+
const copilotOnPath = commandExists('copilot');
|
|
33
|
+
const copilotDirExists = fs.existsSync(path.join(home, '.copilot'));
|
|
34
|
+
if (copilotOnPath || copilotDirExists) {
|
|
26
35
|
targets.push({
|
|
27
36
|
id: 'copilot',
|
|
28
37
|
label: 'GitHub Copilot CLI',
|
|
38
|
+
onPath: copilotOnPath,
|
|
39
|
+
configDirExists: copilotDirExists,
|
|
40
|
+
configDir: path.join(home, '.copilot'),
|
|
29
41
|
install: installCopilot
|
|
30
42
|
});
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
const
|
|
34
|
-
|
|
45
|
+
const claudeOnPath = commandExists('claude');
|
|
46
|
+
const claudeDirExists = fs.existsSync(path.join(home, '.claude'));
|
|
47
|
+
if (claudeOnPath || claudeDirExists) {
|
|
35
48
|
targets.push({
|
|
36
49
|
id: 'claude',
|
|
37
50
|
label: 'Claude CLI',
|
|
51
|
+
onPath: claudeOnPath,
|
|
52
|
+
configDirExists: claudeDirExists,
|
|
53
|
+
configDir: path.join(home, '.claude'),
|
|
38
54
|
install: installClaude
|
|
39
55
|
});
|
|
40
56
|
}
|
package/lib/installers/hooks.js
CHANGED
|
@@ -85,7 +85,9 @@ function runMemoryQuery(args) {
|
|
|
85
85
|
appendLog('resolved direct entry point: ' + entryPoint);
|
|
86
86
|
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
87
87
|
encoding: 'utf8',
|
|
88
|
-
|
|
88
|
+
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
89
|
+
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
90
|
+
timeout: 20000,
|
|
89
91
|
shell: false
|
|
90
92
|
});
|
|
91
93
|
}
|
|
@@ -97,7 +99,7 @@ function runMemoryQuery(args) {
|
|
|
97
99
|
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
98
100
|
return spawnSync('empirical', args, {
|
|
99
101
|
encoding: 'utf8',
|
|
100
|
-
timeout:
|
|
102
|
+
timeout: 20000,
|
|
101
103
|
shell: process.platform === 'win32'
|
|
102
104
|
});
|
|
103
105
|
}
|
|
@@ -310,7 +312,9 @@ function runMemoryQuery(args) {
|
|
|
310
312
|
appendLog('resolved direct entry point: ' + entryPoint);
|
|
311
313
|
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
312
314
|
encoding: 'utf8',
|
|
313
|
-
|
|
315
|
+
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
316
|
+
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
317
|
+
timeout: 20000,
|
|
314
318
|
shell: false
|
|
315
319
|
});
|
|
316
320
|
}
|
|
@@ -318,7 +322,7 @@ function runMemoryQuery(args) {
|
|
|
318
322
|
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
319
323
|
return spawnSync('empirical', args, {
|
|
320
324
|
encoding: 'utf8',
|
|
321
|
-
timeout:
|
|
325
|
+
timeout: 20000,
|
|
322
326
|
shell: process.platform === 'win32'
|
|
323
327
|
});
|
|
324
328
|
}
|
|
@@ -552,7 +556,9 @@ function runMemoryQuery(args) {
|
|
|
552
556
|
appendLog('resolved direct entry point: ' + entryPoint);
|
|
553
557
|
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
554
558
|
encoding: 'utf8',
|
|
555
|
-
|
|
559
|
+
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
560
|
+
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
561
|
+
timeout: 20000,
|
|
556
562
|
shell: false
|
|
557
563
|
});
|
|
558
564
|
}
|
|
@@ -560,7 +566,7 @@ function runMemoryQuery(args) {
|
|
|
560
566
|
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
561
567
|
return spawnSync('empirical', args, {
|
|
562
568
|
encoding: 'utf8',
|
|
563
|
-
timeout:
|
|
569
|
+
timeout: 20000,
|
|
564
570
|
shell: process.platform === 'win32'
|
|
565
571
|
});
|
|
566
572
|
}
|
|
@@ -23,19 +23,119 @@ function runPluginCommand(command, args, options = {}) {
|
|
|
23
23
|
return runResolvedCommand(command, args, { stdio: 'inherit', ...options });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Install/update commands are run with their output captured (and echoed back so the user still
|
|
27
|
+
// sees the host CLI's own progress) because the failure text is what tells a locked plugin folder
|
|
28
|
+
// apart from a real install failure -- see installOneOrKeepExisting below.
|
|
29
|
+
function runPluginInstallCommand(command, args) {
|
|
30
|
+
const result = runResolvedCommand(command, args, { stdio: 'pipe' });
|
|
31
|
+
if (result && result.stdout) process.stdout.write(result.stdout);
|
|
32
|
+
if (result && result.stderr) process.stderr.write(result.stderr);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
function assertPluginInstallSucceeded(agentId, result) {
|
|
27
37
|
if (result && result.status === 0) return;
|
|
28
38
|
const detail = result ? summarizeProcessOutput(result) : 'no process result';
|
|
29
39
|
throw new Error(`${agentId} plugin installation failed.${detail ? ` Details: ${detail}` : ''}`);
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
// ─── Locked plugin folder (Windows) ───────────────────────────────────────────
|
|
43
|
+
// Reproduced 2026-09-17: on Windows, a host CLI replaces an installed plugin by renaming its
|
|
44
|
+
// folder, and Windows refuses to rename a folder any process has open. One open handle -- a
|
|
45
|
+
// running Copilot CLI session, or the Copilot extension in VS Code watching that folder -- is
|
|
46
|
+
// enough, and the host reports it as "Access is denied. (os error 5)". Users read that as a
|
|
47
|
+
// permissions problem and reach for admin rights, which does nothing. Upstream: copilot-cli
|
|
48
|
+
// #4151 / #4095. So when an install fails that way:
|
|
49
|
+
// - if the plugin is already installed, keep it and carry on (the rest of the install -- the
|
|
50
|
+
// instructions block, the hooks -- must not be held hostage to a refresh), and say so;
|
|
51
|
+
// - if it is not installed, fail with the one instruction that fixes it, naming the programs
|
|
52
|
+
// we can see running, instead of the host CLI's bare "Access is denied".
|
|
53
|
+
const LOCKED_FOLDER_PATTERN = /access is denied|os error 5\b|EBUSY|EPERM|being used by another process/i;
|
|
54
|
+
|
|
55
|
+
// Process names (as Windows reports them) that are known to hold plugin folders open per host.
|
|
56
|
+
const LOCK_HOLDER_PROCESS_NAMES = {
|
|
57
|
+
copilot: ['copilot', 'Code'],
|
|
58
|
+
claude: ['claude', 'Code'],
|
|
59
|
+
codex: ['codex', 'Code']
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const LOCK_HOLDER_LABELS = {
|
|
63
|
+
copilot: 'GitHub Copilot CLI',
|
|
64
|
+
claude: 'Claude Code',
|
|
65
|
+
codex: 'Codex CLI',
|
|
66
|
+
Code: 'VS Code'
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function looksLikeLockedPluginFolder(result) {
|
|
70
|
+
if (!result) return false;
|
|
71
|
+
const text = `${result.stdout || ''}\n${result.stderr || ''}\n${result.error ? result.error.message || result.error : ''}`;
|
|
72
|
+
return LOCKED_FOLDER_PATTERN.test(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectLockHolders(agentId) {
|
|
76
|
+
if (process.platform !== 'win32') return [];
|
|
77
|
+
const names = LOCK_HOLDER_PROCESS_NAMES[agentId] || [];
|
|
78
|
+
if (!names.length) return [];
|
|
79
|
+
try {
|
|
80
|
+
const { spawnSync } = require('child_process');
|
|
81
|
+
const script = `Get-Process -Name ${names.join(',')} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName -Unique`;
|
|
82
|
+
const probe = spawnSync('pwsh', ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout: 5000 });
|
|
83
|
+
return String(probe.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
84
|
+
} catch (_error) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function describeLockHolders(agentId, holders) {
|
|
90
|
+
const seen = holders.map((name) => LOCK_HOLDER_LABELS[name] || name);
|
|
91
|
+
const hostLabel = LOCK_HOLDER_LABELS[agentId] || agentId;
|
|
92
|
+
if (seen.length) {
|
|
93
|
+
return `Running now: ${seen.join(', ')}. Close ${seen.length === 1 ? 'it' : 'them'} (every window and terminal session)`;
|
|
94
|
+
}
|
|
95
|
+
return `Close every ${hostLabel} session and every VS Code window`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function currentlyInstalledVersion(agentId, name) {
|
|
99
|
+
try {
|
|
100
|
+
// Lazy: pluginUpdateCheck requires this module, so a top-level require would be a cycle.
|
|
101
|
+
const { readInstalledVersions } = require('../pluginUpdateCheck');
|
|
102
|
+
const versions = readInstalledVersions(agentId, [name]);
|
|
103
|
+
return versions && versions[name] ? versions[name] : null;
|
|
104
|
+
} catch (_error) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs one plugin install and interprets the result. Returns { name, refreshed, keptVersion? }.
|
|
111
|
+
* Throws only for a genuine failure, or for a locked folder when the plugin is not installed at
|
|
112
|
+
* all (there is nothing to keep, and the user must act).
|
|
113
|
+
*/
|
|
114
|
+
function installOneOrKeepExisting(agentId, name, runInstall) {
|
|
115
|
+
const result = runInstall();
|
|
116
|
+
if (result && result.status === 0) return { name, refreshed: true };
|
|
117
|
+
if (!looksLikeLockedPluginFolder(result)) assertPluginInstallSucceeded(agentId, result);
|
|
118
|
+
|
|
119
|
+
const holders = detectLockHolders(agentId);
|
|
120
|
+
const action = describeLockHolders(agentId, holders);
|
|
121
|
+
const keptVersion = currentlyInstalledVersion(agentId, name);
|
|
122
|
+
if (keptVersion) {
|
|
123
|
+
warn(` - ${LOCK_HOLDER_LABELS[agentId] || agentId} could not replace the ${name} plugin folder because another program has it open (Windows reported "Access is denied"; this is not a permissions problem). Kept the installed ${name}@${keptVersion} and continued. ${action}, then run: empirical plugin install ${agentId} to update it.`);
|
|
124
|
+
return { name, refreshed: false, keptVersion, lockedBy: holders };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${agentId} plugin installation failed: ${LOCK_HOLDER_LABELS[agentId] || agentId} could not write the ${name} plugin folder because another program has it open `
|
|
129
|
+
+ `(Windows reported "Access is denied"; running as administrator will not help). ${action}, then run: empirical install`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
32
133
|
function updateOrInstall(agentId, command, updateArgs, installArgs) {
|
|
33
|
-
const updateResult =
|
|
34
|
-
if (updateResult && updateResult.status === 0) return
|
|
134
|
+
const updateResult = runPluginInstallCommand(command, updateArgs);
|
|
135
|
+
if (updateResult && updateResult.status === 0) return { refreshed: true };
|
|
35
136
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
return installResult;
|
|
137
|
+
const name = String(installArgs[2] || '').split('@')[0];
|
|
138
|
+
return installOneOrKeepExisting(agentId, name, () => runPluginInstallCommand(command, installArgs));
|
|
39
139
|
}
|
|
40
140
|
|
|
41
141
|
function normalizePluginNames(pluginNames = [CORE_PLUGIN_NAME]) {
|
|
@@ -92,21 +192,23 @@ function installNativePlugin(agentId, pluginNames = [CORE_PLUGIN_NAME], integrat
|
|
|
92
192
|
applySkillModeVariant(agentId, resolvedIntegrationMode);
|
|
93
193
|
};
|
|
94
194
|
|
|
195
|
+
const outcomes = [];
|
|
196
|
+
|
|
95
197
|
if (agentId === 'claude') {
|
|
96
198
|
// Marketplace registration is intentionally best-effort: native Claude returns non-zero
|
|
97
199
|
// when the same Git marketplace is already registered, but the install command is still
|
|
98
200
|
// authoritative and idempotent.
|
|
99
201
|
runPluginCommand('claude', ['plugin', 'marketplace', 'add', EMPIRICAL_PLUGIN_SOURCE], { stdio: 'pipe' });
|
|
100
202
|
for (const name of names) {
|
|
101
|
-
updateOrInstall(
|
|
203
|
+
outcomes.push(updateOrInstall(
|
|
102
204
|
agentId,
|
|
103
205
|
'claude',
|
|
104
206
|
['plugin', 'update', `${name}@empirical-memory`, '--scope', 'user'],
|
|
105
207
|
['plugin', 'install', name, '--scope', 'user']
|
|
106
|
-
);
|
|
208
|
+
));
|
|
107
209
|
}
|
|
108
210
|
applyVariantIfCore();
|
|
109
|
-
return;
|
|
211
|
+
return { outcomes };
|
|
110
212
|
}
|
|
111
213
|
|
|
112
214
|
if (agentId === 'codex') {
|
|
@@ -114,11 +216,10 @@ function installNativePlugin(agentId, pluginNames = [CORE_PLUGIN_NAME], integrat
|
|
|
114
216
|
// Refresh the Git marketplace snapshot before reinstalling an existing plugin.
|
|
115
217
|
runPluginCommand('codex', ['plugin', 'marketplace', 'upgrade', 'empirical-memory'], { stdio: 'pipe' });
|
|
116
218
|
for (const name of names) {
|
|
117
|
-
|
|
118
|
-
assertPluginInstallSucceeded(agentId, result);
|
|
219
|
+
outcomes.push(installOneOrKeepExisting(agentId, name, () => runPluginInstallCommand('codex', ['plugin', 'add', `${name}@empirical-memory`])));
|
|
119
220
|
}
|
|
120
221
|
applyVariantIfCore();
|
|
121
|
-
return;
|
|
222
|
+
return { outcomes };
|
|
122
223
|
}
|
|
123
224
|
|
|
124
225
|
if (agentId === 'copilot') {
|
|
@@ -127,11 +228,10 @@ function installNativePlugin(agentId, pluginNames = [CORE_PLUGIN_NAME], integrat
|
|
|
127
228
|
runPluginCommand('copilot', ['plugin', 'marketplace', 'add', EMPIRICAL_PLUGIN_SOURCE], { stdio: 'pipe' });
|
|
128
229
|
runPluginCommand('copilot', ['plugin', 'marketplace', 'update', 'empirical-memory'], { stdio: 'pipe' });
|
|
129
230
|
for (const name of names) {
|
|
130
|
-
|
|
131
|
-
assertPluginInstallSucceeded(agentId, result);
|
|
231
|
+
outcomes.push(installOneOrKeepExisting(agentId, name, () => runPluginInstallCommand('copilot', ['plugin', 'install', `${name}@empirical-memory`])));
|
|
132
232
|
}
|
|
133
233
|
applyVariantIfCore();
|
|
134
|
-
return;
|
|
234
|
+
return { outcomes };
|
|
135
235
|
}
|
|
136
236
|
|
|
137
237
|
throw new Error(`Unsupported plugin target: ${agentId}`);
|
|
@@ -143,5 +243,6 @@ module.exports = {
|
|
|
143
243
|
OPTIONAL_PLUGIN_NAMES,
|
|
144
244
|
normalizePluginNames,
|
|
145
245
|
installNativePlugin,
|
|
146
|
-
applySkillModeVariant
|
|
246
|
+
applySkillModeVariant,
|
|
247
|
+
looksLikeLockedPluginFolder
|
|
147
248
|
};
|
package/lib/pluginCommands.js
CHANGED
|
@@ -7,6 +7,8 @@ const {
|
|
|
7
7
|
OPTIONAL_PLUGIN_NAMES = ['empirical-work-history', 'empirical-scar-tissue', 'empirical-todo']
|
|
8
8
|
} = require('./installers/nativePlugin');
|
|
9
9
|
const { checkNativePluginUpdates } = require('./pluginUpdateCheck');
|
|
10
|
+
const { addInstalledHosts } = require('./deviceConfig');
|
|
11
|
+
const { writeCoreMemoryGuidance } = require('./installers/coreMemoryBlock');
|
|
10
12
|
|
|
11
13
|
const SUPPORTED_TARGETS = ['codex', 'copilot', 'claude'];
|
|
12
14
|
|
|
@@ -68,6 +70,15 @@ async function runPluginCommand(args = []) {
|
|
|
68
70
|
if (pluginNames.length === 1) installNativePlugin(target);
|
|
69
71
|
else installNativePlugin(target, pluginNames);
|
|
70
72
|
log(uiSuccess(`${target} plugins installed through its marketplace: ${pluginNames.join(', ')}.`));
|
|
73
|
+
// The skill alone does not make an agent use Empirical; the identity-framed block in the
|
|
74
|
+
// host's global instructions file does. Write it on this path too, not only in `install`.
|
|
75
|
+
writeCoreMemoryGuidance(target, pluginNames);
|
|
76
|
+
// Record the host so `empirical doctor` checks it, and only it, from now on.
|
|
77
|
+
try {
|
|
78
|
+
addInstalledHosts([target]);
|
|
79
|
+
} catch (_error) {
|
|
80
|
+
// Device-config bookkeeping must never fail a plugin install that already succeeded.
|
|
81
|
+
}
|
|
71
82
|
}
|
|
72
83
|
log('');
|
|
73
84
|
log('Native plugin installation does not configure MCP or credentials.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empirical-gauzza/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.32",
|
|
4
4
|
"description": "Persistent cross-tool memory for AI agents. Give Claude Code, Codex, Copilot, Cursor, and ChatGPT one shared memory: save project context once, recall it everywhere via CLI or MCP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"memory",
|