llm-wiki-kit 0.2.3 → 0.2.5

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/src/fs-utils.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from 'fs/promises';
15
15
  import { basename, dirname, join, parse, resolve } from 'path';
16
16
  import { PROJECT_MARKERS } from './constants.js';
17
+ import { cacheHomeRelative, dataHomeRelative, isWindows } from './platform.js';
17
18
  import { normalizeForStorage } from './redaction.js';
18
19
 
19
20
  export function homeDir() {
@@ -21,13 +22,23 @@ export function homeDir() {
21
22
  }
22
23
 
23
24
  export function dataHome() {
25
+ if (isWindows() && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
24
26
  return process.env.XDG_DATA_HOME || join(homeDir(), '.local', 'share');
25
27
  }
26
28
 
27
29
  export function cacheHome() {
30
+ if (isWindows() && process.env.LOCALAPPDATA) return join(process.env.LOCALAPPDATA, 'llm-wiki-kit', 'cache');
28
31
  return process.env.XDG_CACHE_HOME || join(homeDir(), '.cache');
29
32
  }
30
33
 
34
+ export function defaultDataHome(options = {}) {
35
+ return join(homeDir(), dataHomeRelative(options));
36
+ }
37
+
38
+ export function defaultCacheHome(options = {}) {
39
+ return join(homeDir(), cacheHomeRelative(options));
40
+ }
41
+
31
42
  export function kitDataDir() {
32
43
  return join(dataHome(), 'llm-wiki-kit');
33
44
  }
package/src/hook.js CHANGED
@@ -7,6 +7,7 @@ import { recordProject } from './projects.js';
7
7
  import { summarizeForStorage } from './redaction.js';
8
8
  import { buildEntryFromState, clearTurnState, rememberQuestion, rememberTool } from './state.js';
9
9
  import { updateNoticeContext } from './update-notice.js';
10
+ import { removeLegacyOmxWikiSurfaces } from './legacy-omx-wiki.js';
10
11
  import { relative } from 'path';
11
12
 
12
13
  async function readStdinJson() {
@@ -104,6 +105,13 @@ async function autoUpdateManagedProject(projectRoot, eventName) {
104
105
  }
105
106
  }
106
107
 
108
+ async function removeLegacyWikiSurfaces(projectRoot, eventName) {
109
+ if (eventName !== 'SessionStart' && eventName !== 'InstructionsLoaded') return;
110
+ const result = await removeLegacyOmxWikiSurfaces();
111
+ if (!result.changed) return;
112
+ await appendWikiLog(projectRoot, `removed legacy oh-my-codex wiki surfaces: ${result.removed.length}`);
113
+ }
114
+
107
115
  export async function handleHook(provider, explicitEvent) {
108
116
  const payload = await readStdinJson();
109
117
  payload.__provider = provider;
@@ -113,6 +121,7 @@ export async function handleHook(provider, explicitEvent) {
113
121
  await bootstrapProject(projectRoot);
114
122
  await recordProject(projectRoot, 'hook').catch(() => {});
115
123
  await autoUpdateManagedProject(projectRoot, eventName).catch(() => {});
124
+ await removeLegacyWikiSurfaces(projectRoot, eventName).catch(() => {});
116
125
  await appendSessionEnvelope(projectRoot, eventName, payload).catch(() => {});
117
126
  if (eventName === 'SessionStart' || eventName === 'InstructionsLoaded' || eventName === 'UserPromptSubmit') {
118
127
  await recoverStaleTurnStates(projectRoot).catch(() => []);
package/src/install.js CHANGED
@@ -1,53 +1,39 @@
1
- import { realpathSync } from 'fs';
2
1
  import { chmod, lstat, readlink, unlink } from 'fs/promises';
3
- import { spawnSync } from 'child_process';
4
2
  import { join, resolve } from 'path';
5
- import { CLAUDE_EVENTS, CODEX_EVENTS, KIT_NAME } from './constants.js';
3
+ import { CODEX_EVENTS, KIT_NAME } from './constants.js';
4
+ import { detectClaudeVersion, supportedClaudeEvents } from './claude-compat.js';
6
5
  import { backupFile, exists, homeDir, readJson, safeSymlink, writeJson } from './fs-utils.js';
7
6
  import { maintenanceSummary } from './maintenance.js';
7
+ import {
8
+ commandForNodeScript,
9
+ commandMatchesRuntime,
10
+ commandPaths as findCommandPaths,
11
+ inspectCommandPath,
12
+ isWindows,
13
+ realpathOrOriginal,
14
+ samePath,
15
+ sameResolvedPath,
16
+ } from './platform.js';
8
17
  import { inspectProjectState } from './project-state.js';
9
18
  import { bootstrapProject } from './project.js';
10
19
  import { recordProject } from './projects.js';
11
20
  import { binPath, detectInstallSource, packageRoot, runtimeVersion } from './version.js';
12
21
 
13
- function shellQuote(value) {
14
- return `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`;
22
+ export function hookCommand(provider, eventName, options = {}) {
23
+ return commandForNodeScript(binPath, ['hook', provider, eventName], options);
15
24
  }
16
25
 
17
- export function hookCommand(provider, eventName) {
18
- return `${shellQuote(process.execPath)} ${shellQuote(binPath)} hook ${provider} ${eventName}`;
19
- }
20
-
21
- function llmWikiCommandPaths() {
22
- const result = spawnSync('sh', ['-lc', 'which -a llm-wiki 2>/dev/null || true'], {
23
- encoding: 'utf8',
24
- });
25
- if (result.error) return [];
26
- return [...new Set(result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
27
- }
28
-
29
- function realpathOrOriginal(path) {
30
- if (!path) return null;
31
- try {
32
- return realpathSync(path);
33
- } catch {
34
- return path;
35
- }
36
- }
37
-
38
- function sameResolvedPath(left, right) {
39
- const resolvedLeft = realpathOrOriginal(left);
40
- const resolvedRight = realpathOrOriginal(right);
41
- return Boolean(resolvedLeft && resolvedRight && resolvedLeft === resolvedRight);
26
+ function isKitPath(path) {
27
+ return String(path || '').replace(/\\/g, '/').includes('/llm-wiki-kit/');
42
28
  }
43
29
 
44
- function samePath(left, right) {
45
- if (!left || !right) return false;
46
- return resolve(left) === resolve(right);
30
+ function isKitHookEntry(entry) {
31
+ const serialized = normalizeHookPathText(JSON.stringify(entry || {}));
32
+ return serialized.includes(KIT_NAME) || serialized.includes(normalizeHookPathText(binPath)) || serialized.includes('/llm-wiki-kit/');
47
33
  }
48
34
 
49
- function isKitPath(path) {
50
- return String(path || '').replace(/\\/g, '/').includes('/llm-wiki-kit/');
35
+ function normalizeHookPathText(value) {
36
+ return String(value || '').replace(/\\\\/g, '/').replace(/\\/g, '/');
51
37
  }
52
38
 
53
39
  async function inspectLocalBin(localBinPath) {
@@ -79,7 +65,7 @@ async function inspectLocalBin(localBinPath) {
79
65
  }
80
66
 
81
67
  async function reconcileLocalBin(localBinPath) {
82
- const commandPathsBefore = llmWikiCommandPaths();
68
+ const commandPathsBefore = await findCommandPaths('llm-wiki');
83
69
  const localBefore = await inspectLocalBin(localBinPath);
84
70
  const alternateRuntimeCommand = commandPathsBefore.some((path) => (
85
71
  !samePath(path, localBinPath) && sameResolvedPath(path, binPath)
@@ -108,7 +94,7 @@ async function reconcileLocalBin(localBinPath) {
108
94
  await safeSymlink(binPath, localBinPath);
109
95
  }
110
96
 
111
- const commandPathsAfter = llmWikiCommandPaths();
97
+ const commandPathsAfter = await findCommandPaths('llm-wiki');
112
98
  const localAfter = await inspectLocalBin(localBinPath);
113
99
  return {
114
100
  ...localAfter,
@@ -120,6 +106,23 @@ async function reconcileLocalBin(localBinPath) {
120
106
  };
121
107
  }
122
108
 
109
+ async function reconcileWindowsCommand() {
110
+ const commandPathsBefore = await findCommandPaths('llm-wiki', { platform: 'win32' });
111
+ const commandPath = commandPathsBefore[0] || null;
112
+ const command = await inspectCommandPath(commandPath, binPath, {
113
+ platform: 'win32',
114
+ installSource: detectInstallSource(),
115
+ });
116
+ return {
117
+ ...command,
118
+ action: command.matchesRuntime ? 'skipped-windows-npm-shim-available' : 'skipped-windows-no-local-shim',
119
+ alternateRuntimeCommand: command.matchesRuntime,
120
+ commandPathsBefore,
121
+ commandPathsAfter: commandPathsBefore,
122
+ commandPath,
123
+ };
124
+ }
125
+
123
126
  function addHook(hooks, eventName, command, options = {}) {
124
127
  hooks[eventName] = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
125
128
  const already = hooks[eventName].some((entry) => (
@@ -136,6 +139,7 @@ function addHook(hooks, eventName, command, options = {}) {
136
139
  },
137
140
  ],
138
141
  };
142
+ if (options.commandWindows) entry.hooks[0].commandWindows = options.commandWindows;
139
143
  if (options.matcher) entry.matcher = options.matcher;
140
144
  hooks[eventName].push(entry);
141
145
  return true;
@@ -145,24 +149,60 @@ function removeKitHooks(hooks) {
145
149
  let changed = false;
146
150
  for (const [eventName, entries] of Object.entries(hooks || {})) {
147
151
  if (!Array.isArray(entries)) continue;
148
- const next = entries.filter((entry) => {
149
- const serialized = JSON.stringify(entry);
150
- return !serialized.includes(KIT_NAME) && !serialized.includes(binPath);
151
- });
152
+ const next = entries.filter((entry) => !isKitHookEntry(entry));
152
153
  if (next.length !== entries.length) {
153
- hooks[eventName] = next;
154
+ if (next.length > 0) hooks[eventName] = next;
155
+ else delete hooks[eventName];
154
156
  changed = true;
155
157
  }
156
158
  }
157
159
  return changed;
158
160
  }
159
161
 
162
+ function removeUnsupportedKitClaudeHooks(hooks, supportedEvents) {
163
+ const supported = new Set(supportedEvents);
164
+ const removed = [];
165
+ for (const [eventName, entries] of Object.entries(hooks || {})) {
166
+ if (supported.has(eventName) || !Array.isArray(entries)) continue;
167
+ const next = entries.filter((entry) => !isKitHookEntry(entry));
168
+ if (next.length !== entries.length) {
169
+ removed.push(eventName);
170
+ if (next.length > 0) hooks[eventName] = next;
171
+ else delete hooks[eventName];
172
+ }
173
+ }
174
+ return [...new Set(removed)];
175
+ }
176
+
177
+ function unsupportedKitClaudeEvents(hooks, supportedEvents) {
178
+ const supported = new Set(supportedEvents);
179
+ return Object.entries(hooks || {})
180
+ .filter(([eventName, entries]) => (
181
+ !supported.has(eventName) &&
182
+ Array.isArray(entries) &&
183
+ entries.some((entry) => isKitHookEntry(entry))
184
+ ))
185
+ .map(([eventName]) => eventName)
186
+ .sort();
187
+ }
188
+
189
+ function hookTextIncludes(text, value) {
190
+ return normalizeHookPathText(text).includes(normalizeHookPathText(value));
191
+ }
192
+
193
+ function hookEventIncludesBin(entries) {
194
+ return hookTextIncludes(JSON.stringify(entries || {}), binPath);
195
+ }
196
+
160
197
  export async function install(options = {}) {
161
198
  const workspace = resolve(options.workspace || process.cwd());
162
199
  await chmod(binPath, 0o755).catch(() => {});
163
- const localBin = join(homeDir(), '.local', 'bin');
164
- const localBinPath = join(localBin, 'llm-wiki');
165
- const localBinResult = await reconcileLocalBin(localBinPath);
200
+ const platform = options.platform || process.platform;
201
+ const localBin = isWindows({ platform }) ? null : join(homeDir(), '.local', 'bin');
202
+ const localBinPath = localBin ? join(localBin, 'llm-wiki') : null;
203
+ const localBinResult = isWindows({ platform })
204
+ ? await reconcileWindowsCommand()
205
+ : await reconcileLocalBin(localBinPath);
166
206
  if (!options.noProject) {
167
207
  await bootstrapProject(workspace, { profile: options.profile || 'standard', recordState: true });
168
208
  await recordProject(workspace, 'install');
@@ -182,7 +222,9 @@ export async function install(options = {}) {
182
222
  }
183
223
  for (const eventName of CODEX_EVENTS) {
184
224
  const matcher = eventName === 'SessionStart' ? 'startup|resume|clear' : undefined;
185
- if (addHook(codex.hooks, eventName, hookCommand('codex', eventName), { matcher })) {
225
+ const command = hookCommand('codex', eventName, { platform });
226
+ const commandWindows = isWindows({ platform }) ? hookCommand('codex', eventName, { platform: 'win32' }) : undefined;
227
+ if (addHook(codex.hooks, eventName, command, { matcher, commandWindows })) {
186
228
  codexChanged = true;
187
229
  changed.push(`codex:${eventName}`);
188
230
  }
@@ -196,14 +238,21 @@ export async function install(options = {}) {
196
238
  if (options.claude !== false) {
197
239
  const claude = (await readJson(claudeSettingsPath, null)) || {};
198
240
  claude.hooks = claude.hooks || {};
241
+ const claudeDetection = detectClaudeVersion();
242
+ const claudeEvents = supportedClaudeEvents(claudeDetection);
199
243
  let claudeChanged = false;
200
244
  if (options.replaceHooks && removeKitHooks(claude.hooks)) {
201
245
  claudeChanged = true;
202
246
  changed.push('claude:replaced');
203
247
  }
204
- for (const eventName of CLAUDE_EVENTS) {
248
+ const removedUnsupported = removeUnsupportedKitClaudeHooks(claude.hooks, claudeEvents);
249
+ if (removedUnsupported.length > 0) {
250
+ claudeChanged = true;
251
+ changed.push(...removedUnsupported.map((eventName) => `claude:removed-unsupported:${eventName}`));
252
+ }
253
+ for (const eventName of claudeEvents) {
205
254
  const matcher = eventName === 'SessionStart' ? 'startup|resume|clear' : undefined;
206
- if (addHook(claude.hooks, eventName, hookCommand('claude', eventName), { matcher })) {
255
+ if (addHook(claude.hooks, eventName, hookCommand('claude', eventName, { platform }), { matcher })) {
207
256
  claudeChanged = true;
208
257
  changed.push(`claude:${eventName}`);
209
258
  }
@@ -256,30 +305,47 @@ export async function uninstall(options = {}) {
256
305
 
257
306
  export async function status(options = {}) {
258
307
  const workspace = resolve(options.workspace || process.cwd());
308
+ const platform = options.platform || process.platform;
259
309
  const codexHooksPath = join(homeDir(), '.codex', 'hooks.json');
260
310
  const claudeSettingsPath = join(homeDir(), '.claude', 'settings.json');
261
311
  const codex = await readJson(codexHooksPath, {});
262
312
  const claude = await readJson(claudeSettingsPath, {});
263
- const codexInstalled = JSON.stringify(codex.hooks || {}).includes(binPath);
264
- const claudeInstalled = JSON.stringify(claude.hooks || {}).includes(binPath);
265
- const commandPaths = llmWikiCommandPaths();
266
- const commandPath = commandPaths[0] || null;
313
+ const claudeDetection = detectClaudeVersion();
314
+ const claudeEvents = supportedClaudeEvents(claudeDetection);
315
+ const claudeMissingEvents = claudeEvents.filter((eventName) => !hookEventIncludesBin(claude.hooks?.[eventName] || []));
316
+ const claudeUnsupportedKitEvents = unsupportedKitClaudeEvents(claude.hooks || {}, claudeEvents);
317
+ const codexInstalled = hookEventIncludesBin(codex.hooks || {});
318
+ const claudeInstalled = claudeMissingEvents.length === 0 && claudeUnsupportedKitEvents.length === 0;
319
+ const discoveredCommandPaths = await findCommandPaths('llm-wiki', { platform });
320
+ const commandPath = discoveredCommandPaths[0] || null;
267
321
  const resolvedCommandPath = realpathOrOriginal(commandPath);
268
322
  const resolvedBinPath = realpathOrOriginal(binPath);
269
- const localBinPath = join(homeDir(), '.local', 'bin', 'llm-wiki');
270
- const localBin = await inspectLocalBin(localBinPath);
323
+ const localBinPath = isWindows({ platform }) ? null : join(homeDir(), '.local', 'bin', 'llm-wiki');
324
+ const localBin = localBinPath
325
+ ? await inspectLocalBin(localBinPath)
326
+ : await inspectCommandPath(commandPath, binPath, { platform, installSource: detectInstallSource() });
327
+ const commandMatches = await commandMatchesRuntime(commandPath, binPath, {
328
+ platform,
329
+ installSource: detectInstallSource(),
330
+ });
271
331
  return {
272
332
  workspace,
333
+ platform,
273
334
  runtimeVersion: runtimeVersion(),
274
335
  packageRoot,
275
336
  installSource: detectInstallSource(),
276
337
  binPath,
277
338
  commandPath,
278
- commandPaths,
279
- commandMatchesRuntime: Boolean(resolvedCommandPath && resolvedBinPath && resolvedCommandPath === resolvedBinPath),
339
+ commandPaths: discoveredCommandPaths,
340
+ commandMatchesRuntime: Boolean(commandMatches || (resolvedCommandPath && resolvedBinPath && resolvedCommandPath === resolvedBinPath)),
280
341
  localBin,
281
342
  codexInstalled,
282
343
  claudeInstalled,
344
+ claudeVersion: claudeDetection.version || 'unknown',
345
+ claudeModernHooks: claudeDetection.modern,
346
+ claudeSupportedEvents: claudeEvents,
347
+ claudeMissingEvents,
348
+ claudeUnsupportedKitEvents,
283
349
  hooksCurrent: codexInstalled && claudeInstalled,
284
350
  codexHooksPath,
285
351
  claudeSettingsPath,
@@ -0,0 +1,222 @@
1
+ import { mkdtemp, rm, readdir, writeFile } from 'fs/promises';
2
+ import { spawnSync } from 'child_process';
3
+ import { join, resolve } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { backupFile, exists, homeDir, readJson, readText, writeJson, writeText } from './fs-utils.js';
6
+
7
+ const CODEX_CONFIG = '.codex/config.toml';
8
+ const TOML_TABLE = /^\s*\[([^\]]+)\]\s*$/;
9
+
10
+ function normalizeTomlHeader(header) {
11
+ return String(header || '').replace(/\s+/g, '').replace(/'/g, '"');
12
+ }
13
+
14
+ function isLegacyOmxWikiTable(header) {
15
+ const normalized = normalizeTomlHeader(header);
16
+ if (normalized === 'mcp_servers.omx_wiki') return true;
17
+ return normalized.includes('oh-my-codex') &&
18
+ normalized.includes('.mcp_servers.omx_wiki');
19
+ }
20
+
21
+ export function removeLegacyOmxWikiTomlTables(text) {
22
+ const lines = String(text || '').split(/\r?\n/);
23
+ const output = [];
24
+ let changed = false;
25
+ let skipping = false;
26
+
27
+ for (const line of lines) {
28
+ const table = line.match(TOML_TABLE);
29
+ if (table) {
30
+ skipping = isLegacyOmxWikiTable(table[1]);
31
+ if (skipping) {
32
+ changed = true;
33
+ continue;
34
+ }
35
+ }
36
+ if (!skipping) output.push(line);
37
+ }
38
+
39
+ if (!changed) return { changed: false, text: String(text || '') };
40
+ return {
41
+ changed: true,
42
+ text: `${output.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/g, '')}\n`,
43
+ };
44
+ }
45
+
46
+ function sourceRootsFromConfig(text) {
47
+ const roots = new Set();
48
+ const sourceRe = /^\s*source\s*=\s*["']([^"']*oh-my-codex[^"']*)["']\s*$/gm;
49
+ let match;
50
+ while ((match = sourceRe.exec(String(text || '')))) {
51
+ roots.add(match[1]);
52
+ }
53
+ return roots;
54
+ }
55
+
56
+ function configuredPackageRoots(home, configText) {
57
+ const roots = new Set([
58
+ '/usr/lib/node_modules/oh-my-codex',
59
+ '/usr/local/lib/node_modules/oh-my-codex',
60
+ join(home, '.local', 'lib', 'node_modules', 'oh-my-codex'),
61
+ ]);
62
+ for (const root of sourceRootsFromConfig(configText)) roots.add(root);
63
+ for (const root of String(process.env.LLM_WIKI_KIT_OMX_WIKI_ROOTS || '').split(':').filter(Boolean)) {
64
+ roots.add(root);
65
+ }
66
+ return roots;
67
+ }
68
+
69
+ function safeLegacyWikiSkillDir(path) {
70
+ const normalized = resolve(path).replace(/\\/g, '/');
71
+ return /(?:^|\/)oh-my-codex(?:\/|$)/.test(normalized) &&
72
+ /\/skills\/wiki$/.test(normalized);
73
+ }
74
+
75
+ function safeLegacyWikiMcpPath(path) {
76
+ const normalized = resolve(path).replace(/\\/g, '/');
77
+ return /(?:^|\/)oh-my-codex(?:\/|$)/.test(normalized) &&
78
+ /\/\.mcp\.json$/.test(normalized);
79
+ }
80
+
81
+ async function collectCachedSkillDirs(root, maxDepth = 8) {
82
+ const found = [];
83
+ async function walk(dir, depth) {
84
+ if (depth < 0) return;
85
+ let entries = [];
86
+ try {
87
+ entries = await readdir(dir, { withFileTypes: true });
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory()) continue;
93
+ const full = join(dir, entry.name);
94
+ const normalized = full.replace(/\\/g, '/');
95
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
96
+ if (entry.name === 'wiki' && normalized.includes('/oh-my-codex/') && normalized.includes('/skills/wiki')) {
97
+ found.push(full);
98
+ continue;
99
+ }
100
+ await walk(full, depth - 1);
101
+ }
102
+ }
103
+ await walk(root, maxDepth);
104
+ return found;
105
+ }
106
+
107
+ async function legacyWikiSkillDirs(home, configText) {
108
+ const dirs = new Set();
109
+ for (const root of configuredPackageRoots(home, configText)) {
110
+ dirs.add(join(root, 'skills', 'wiki'));
111
+ dirs.add(join(root, 'plugins', 'oh-my-codex', 'skills', 'wiki'));
112
+ }
113
+ for (const root of [
114
+ join(home, '.codex', 'plugins', 'cache'),
115
+ join(home, '.codex', '.tmp'),
116
+ ]) {
117
+ for (const dir of await collectCachedSkillDirs(root)) dirs.add(dir);
118
+ }
119
+ return [...dirs].filter(safeLegacyWikiSkillDir);
120
+ }
121
+
122
+ async function removeSkillDir(dir) {
123
+ if (!(await exists(dir))) return false;
124
+ try {
125
+ await rm(dir, { recursive: true, force: true });
126
+ return true;
127
+ } catch (error) {
128
+ if (!['EACCES', 'EPERM'].includes(error?.code)) throw error;
129
+ }
130
+ if (process.env.LLM_WIKI_KIT_OMX_WIKI_SUDO === '0') return false;
131
+ const result = spawnSync('sudo', ['-n', 'rm', '-rf', dir], {
132
+ encoding: 'utf8',
133
+ timeout: 5000,
134
+ });
135
+ return result.status === 0 && !(await exists(dir));
136
+ }
137
+
138
+ async function removePluginMcpEntry(path) {
139
+ const json = await readJson(path, null);
140
+ if (!json?.mcpServers?.omx_wiki) return false;
141
+ delete json.mcpServers.omx_wiki;
142
+ try {
143
+ await writeJson(path, json);
144
+ } catch (error) {
145
+ if (!['EACCES', 'EPERM'].includes(error?.code) || !safeLegacyWikiMcpPath(path)) throw error;
146
+ if (process.env.LLM_WIKI_KIT_OMX_WIKI_SUDO === '0') return false;
147
+ const tempDir = await mkdtemp(join(tmpdir(), 'llm-wiki-kit-mcp-'));
148
+ const tempPath = join(tempDir, '.mcp.json');
149
+ await writeFile(tempPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
150
+ const result = spawnSync('sudo', ['-n', 'cp', tempPath, path], {
151
+ encoding: 'utf8',
152
+ timeout: 5000,
153
+ });
154
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
155
+ if (result.status !== 0) return false;
156
+ }
157
+ return true;
158
+ }
159
+
160
+ async function removeWritablePluginMcpEntries(home, configText) {
161
+ const changed = [];
162
+ const candidates = new Set();
163
+ for (const root of configuredPackageRoots(home, configText)) {
164
+ candidates.add(join(root, 'plugins', 'oh-my-codex', '.mcp.json'));
165
+ }
166
+ for (const root of [
167
+ join(home, '.codex', 'plugins', 'cache'),
168
+ join(home, '.codex', '.tmp'),
169
+ ]) {
170
+ const dirs = await collectCachedSkillDirs(root);
171
+ for (const dir of dirs) {
172
+ candidates.add(resolve(dir, '..', '..', '.mcp.json'));
173
+ }
174
+ }
175
+ for (const candidate of candidates) {
176
+ try {
177
+ if (await removePluginMcpEntry(candidate)) changed.push(candidate);
178
+ } catch {
179
+ // Root-owned package manifests may be read-only to the hook user. The
180
+ // active Codex config cleanup is the enforcement point in that case.
181
+ }
182
+ }
183
+ return changed;
184
+ }
185
+
186
+ export async function removeLegacyOmxWikiSurfaces(options = {}) {
187
+ if (process.env.LLM_WIKI_KIT_OMX_WIKI_GUARD === '0') {
188
+ return { changed: false, removed: [], configChanged: false };
189
+ }
190
+
191
+ const home = options.home || homeDir();
192
+ const configPath = options.configPath || join(home, CODEX_CONFIG);
193
+ const originalConfig = await readText(configPath, null);
194
+ const removed = [];
195
+ let configText = originalConfig || '';
196
+ let configChanged = false;
197
+
198
+ if (originalConfig !== null) {
199
+ const cleaned = removeLegacyOmxWikiTomlTables(originalConfig);
200
+ if (cleaned.changed) {
201
+ await backupFile(configPath, 'codex-config-before-omx-wiki-removal.toml');
202
+ await writeText(configPath, cleaned.text);
203
+ configText = cleaned.text;
204
+ configChanged = true;
205
+ removed.push('codex-config:omx_wiki');
206
+ }
207
+ }
208
+
209
+ for (const dir of await legacyWikiSkillDirs(home, configText)) {
210
+ if (await removeSkillDir(dir)) removed.push(`skill:${dir}`);
211
+ }
212
+
213
+ for (const path of await removeWritablePluginMcpEntries(home, configText)) {
214
+ removed.push(`mcp:${path}`);
215
+ }
216
+
217
+ return {
218
+ changed: removed.length > 0,
219
+ removed,
220
+ configChanged,
221
+ };
222
+ }