@empirical-gauzza/cli 1.0.31 → 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.
@@ -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 = runPluginCommand(command, updateArgs);
34
- if (updateResult && updateResult.status === 0) return updateResult;
134
+ const updateResult = runPluginInstallCommand(command, updateArgs);
135
+ if (updateResult && updateResult.status === 0) return { refreshed: true };
35
136
 
36
- const installResult = runPluginCommand(command, installArgs);
37
- assertPluginInstallSucceeded(agentId, installResult);
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
- const result = runPluginCommand('codex', ['plugin', 'add', `${name}@empirical-memory`]);
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
- const result = runPluginCommand('copilot', ['plugin', 'install', `${name}@empirical-memory`]);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empirical-gauzza/cli",
3
- "version": "1.0.31",
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",