@yugabytedb/meko-mcp 1.0.8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +242 -44
  2. package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
  3. package/assets/hooks-handlers/cursor-session-start.sh +42 -0
  4. package/assets/hooks-handlers/lib/capture.js +457 -107
  5. package/assets/hooks-handlers/lib/package.json +1 -0
  6. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  7. package/assets/scripts/package-desktop-skill.sh +36 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  19. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  20. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
  21. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
  31. package/bin/create-meko-setup.mjs +151 -22
  32. package/lib/clients/claude-code.mjs +378 -68
  33. package/lib/clients/claude-desktop.mjs +45 -8
  34. package/lib/clients/codex.mjs +1209 -0
  35. package/lib/clients/common.mjs +223 -1
  36. package/lib/clients/cursor.mjs +118 -13
  37. package/lib/clients/index.mjs +2 -0
  38. package/lib/config.mjs +56 -0
  39. package/lib/detect.mjs +52 -44
  40. package/lib/installer.mjs +11 -4
  41. package/lib/mcp-transport.mjs +61 -2
  42. package/lib/migrate/adapters/memory-md.mjs +13 -6
  43. package/lib/migrate/datapack.mjs +167 -0
  44. package/lib/migrate/id.mjs +127 -9
  45. package/lib/migrate/orchestrator.mjs +68 -10
  46. package/lib/migrate.mjs +346 -3
  47. package/lib/prompt.mjs +22 -4
  48. package/lib/validate.mjs +313 -9
  49. package/lib/version-check.mjs +178 -0
  50. package/package.json +2 -1
  51. package/scripts/bundle-assets.mjs +8 -0
@@ -1,5 +1,16 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync, mkdirSync, symlinkSync, cpSync, lstatSync, unlinkSync, rmSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ symlinkSync,
6
+ cpSync,
7
+ lstatSync,
8
+ statSync,
9
+ unlinkSync,
10
+ rmSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ } from "node:fs";
3
14
  import { dirname, join, resolve } from "node:path";
4
15
  import { fileURLToPath } from "node:url";
5
16
 
@@ -98,14 +109,94 @@ export function shellQuote(value) {
98
109
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
99
110
  }
100
111
 
112
+ // statSync error codes that mean a symlink is broken — its target cannot be
113
+ // resolved to a real inode. We deliberately do NOT include EACCES / EPERM:
114
+ // those mean "target may exist but I can't see it", and silently removing
115
+ // someone else's link based on a permission failure is unsafe.
116
+ const BROKEN_SYMLINK_STAT_CODES = new Set([
117
+ "ENOENT", // target does not exist (the textbook "dangling symlink")
118
+ "ENOTDIR", // a non-directory component appears mid-path (e.g. link -> file/child)
119
+ "ELOOP", // too many redirections (cyclic symlinks)
120
+ ]);
121
+
122
+ /**
123
+ * True when `targetPath` is a symlink whose target cannot be resolved. A
124
+ * regular file/dir or a working symlink returns false; a missing path also
125
+ * returns false. See `BROKEN_SYMLINK_STAT_CODES` for which resolution
126
+ * failures count.
127
+ *
128
+ * Why this matters: `existsSync` returns false for a broken symlink, so a
129
+ * naive `if (existsSync(dest)) {...}` branch falls through and tries to write
130
+ * over the symlink. On macOS, `cpSync` then aborts the entire process via an
131
+ * uncaught libc++ `filesystem_error` ("Operation not supported" inside
132
+ * `std::filesystem::equivalent`) whenever one operand is a broken symlink —
133
+ * not just the ENOENT case but also ENOTDIR (`link -> file/child`) and ELOOP.
134
+ * Detecting any of these lets callers either treat the path as an existing
135
+ * entry or clear it before writing.
136
+ *
137
+ * @param {string} targetPath
138
+ * @returns {boolean}
139
+ */
140
+ export function isDanglingSymlink(targetPath) {
141
+ let stat;
142
+ try {
143
+ stat = lstatSync(targetPath);
144
+ } catch {
145
+ return false;
146
+ }
147
+ if (!stat.isSymbolicLink()) return false;
148
+ try {
149
+ statSync(targetPath);
150
+ return false;
151
+ } catch (err) {
152
+ return BROKEN_SYMLINK_STAT_CODES.has(err.code);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Unlink that tolerates the narrow race window where another process
158
+ * already removed `targetPath` between our check and our write (ENOENT)
159
+ * but propagates every other failure (EACCES, EPERM, EBUSY, etc.) as a
160
+ * normal JS error.
161
+ *
162
+ * Why not `removePath`: that helper swallows ALL errors, which is fine
163
+ * for cleanup paths but dangerous in front of `cpSync`. If unlink truly
164
+ * fails (e.g. read-only parent dir), the broken symlink is still on
165
+ * disk; cpSync would then dive into the macOS libc++ `filesystem_error`
166
+ * abort path described in `isDanglingSymlink`. Surfacing the unlink
167
+ * error here lets the surrounding installer surface a clean error
168
+ * instead of aborting the process.
169
+ *
170
+ * @param {string} targetPath
171
+ */
172
+ function unlinkAllowingMissing(targetPath) {
173
+ try {
174
+ unlinkSync(targetPath);
175
+ } catch (err) {
176
+ if (err && err.code === "ENOENT") return;
177
+ throw err;
178
+ }
179
+ }
180
+
101
181
  /**
102
182
  * Create symlink when possible; fallback to copy.
183
+ *
184
+ * Pre-clears a broken symlink at `destination` so the copy fallback path
185
+ * doesn't crash. See `isDanglingSymlink` above for the macOS libc++ trap.
186
+ * Uses `unlinkAllowingMissing` rather than `removePath` so an unlink
187
+ * failure (e.g. read-only parent dir) surfaces as a normal JS error
188
+ * instead of silently leaving the broken link in place — otherwise
189
+ * `cpSync` below would still abort the process.
190
+ *
103
191
  * @param {string} source
104
192
  * @param {string} destination
105
193
  * @returns {"symlink"|"copy"}
106
194
  */
107
195
  export function symlinkOrCopy(source, destination) {
108
196
  mkdirSync(dirname(destination), { recursive: true });
197
+ if (isDanglingSymlink(destination)) {
198
+ unlinkAllowingMissing(destination);
199
+ }
109
200
  try {
110
201
  symlinkSync(source, destination, "junction");
111
202
  return "symlink";
@@ -131,3 +222,134 @@ export function removePath(targetPath) {
131
222
  // no-op
132
223
  }
133
224
  }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Installer version markers
228
+ // ---------------------------------------------------------------------------
229
+ //
230
+ // Each artifact the installer owns (a deployed skill dir, a built .skill
231
+ // bundle, the Claude Code meko-capture dir, etc.) gets a `.meko-installer`
232
+ // marker file stamped with the installer's version. On re-run we compare the
233
+ // marker's version against the current installer to decide whether we need
234
+ // to force-refresh a managed artifact. Without a marker the artifact is
235
+ // treated as user-managed and left alone — we never clobber a dir we didn't
236
+ // produce ourselves.
237
+ //
238
+ // The marker format is deliberately plain text, one `key=value` per line,
239
+ // so a human inspecting it with `cat` can immediately see what produced
240
+ // the artifact and when.
241
+
242
+ const MARKER_FILENAME = ".meko-installer";
243
+
244
+ /**
245
+ * Read the installer's package.json `version` field. Mirrors the read
246
+ * performed in bin/create-meko-setup.mjs:56-59 so the marker and the
247
+ * CLI's --version report agree.
248
+ *
249
+ * @returns {string}
250
+ */
251
+ export function getInstallerVersion() {
252
+ const libDir = dirname(fileURLToPath(import.meta.url));
253
+ const pkgPath = resolve(libDir, "..", "..", "package.json");
254
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
255
+ return pkg.version;
256
+ }
257
+
258
+ /**
259
+ * Write a `.meko-installer` marker inside `dir`. Format is one `key=value`
260
+ * line per field, e.g.
261
+ *
262
+ * version=1.0.8
263
+ * installedAt=2026-04-30T14:22:11.123Z
264
+ *
265
+ * Extra key/value pairs can be appended via `extra`. `dir` must exist; this
266
+ * function will not create it (the caller has already done mkdir).
267
+ *
268
+ * @param {string} dir
269
+ * @param {{extra?: Record<string,string>}} [opts]
270
+ * @returns {{path: string, version: string, installedAt: string}}
271
+ */
272
+ export function writeInstallerMarker(dir, { extra = {} } = {}) {
273
+ const version = getInstallerVersion();
274
+ const installedAt = new Date().toISOString();
275
+ const lines = [`version=${version}`, `installedAt=${installedAt}`];
276
+ for (const [key, value] of Object.entries(extra)) {
277
+ lines.push(`${key}=${value}`);
278
+ }
279
+ const markerPath = join(dir, MARKER_FILENAME);
280
+ writeFileSync(markerPath, lines.join("\n") + "\n", "utf8");
281
+ return { path: markerPath, version, installedAt };
282
+ }
283
+
284
+ /**
285
+ * Parse a `.meko-installer` marker from `dir`. Returns null if the marker
286
+ * is missing or unreadable. Callers treat null as "not ours / user-managed".
287
+ *
288
+ * Marker format evolved across installer versions:
289
+ * - pre-1.0.9: a single line containing the word "managed". No version.
290
+ * - 1.0.9+: `key=value` lines, including `version=` and `installedAt=`.
291
+ *
292
+ * Both shapes indicate "we own this artifact"; the caller needs to know
293
+ * which shape it got so it can decide whether to force a refresh. Legacy
294
+ * markers have no version to compare against, so they are returned with
295
+ * `legacy: true` and no `version` — `markerIsStale` then treats them as
296
+ * stale (any current version mismatches) and triggers a refresh. That's
297
+ * exactly the behavior users hitting the "stale skill" bug from 1.0.8
298
+ * need on their next re-install.
299
+ *
300
+ * @param {string} dir
301
+ * @returns {{version?: string, installedAt?: string, legacy?: boolean, [k: string]: string|boolean|undefined}|null}
302
+ */
303
+ export function readInstallerMarker(dir) {
304
+ const markerPath = join(dir, MARKER_FILENAME);
305
+ let raw;
306
+ try {
307
+ raw = readFileSync(markerPath, "utf8");
308
+ } catch {
309
+ return null;
310
+ }
311
+ const out = {};
312
+ for (const line of raw.split(/\r?\n/)) {
313
+ const trimmed = line.trim();
314
+ if (!trimmed) continue;
315
+ const eq = trimmed.indexOf("=");
316
+ if (eq <= 0) continue;
317
+ const key = trimmed.slice(0, eq).trim();
318
+ const value = trimmed.slice(eq + 1).trim();
319
+ if (!key) continue;
320
+ out[key] = value;
321
+ }
322
+ if (out.version) return out;
323
+
324
+ // Legacy marker path: pre-1.0.9 installs wrote the literal text
325
+ // `managed\n` (no `=`). Recognize it so we don't mistake an older
326
+ // managed dir for a user-managed one. Returning a parsed object with
327
+ // `legacy: true` lets markerIsStale do the right thing (stale → refresh).
328
+ if (raw.trim() === "managed") {
329
+ return { legacy: true };
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * True when the artifact at `dir` was produced by a different installer
336
+ * version than `currentVersion`. Also true when the marker is missing or
337
+ * unparseable — in that case the caller still needs to check whether the
338
+ * artifact dir exists (missing marker + missing dir = fresh install; missing
339
+ * marker + existing dir = user-managed, handle separately).
340
+ *
341
+ * Strict inequality by design — any version change forces a refresh, even
342
+ * a downgrade. We don't want to play semver-comparison games when the
343
+ * artifact content may have changed in either direction.
344
+ *
345
+ * @param {string} dir
346
+ * @param {string} currentVersion
347
+ * @returns {boolean}
348
+ */
349
+ export function markerIsStale(dir, currentVersion) {
350
+ const marker = readInstallerMarker(dir);
351
+ if (!marker) return true;
352
+ // Legacy `managed\n` marker has no version recorded — always stale.
353
+ if (marker.legacy) return true;
354
+ return marker.version !== currentVersion;
355
+ }
@@ -2,7 +2,6 @@ import {
2
2
  existsSync,
3
3
  lstatSync,
4
4
  readlinkSync,
5
- writeFileSync,
6
5
  mkdirSync,
7
6
  } from "node:fs";
8
7
  import { homedir } from "node:os";
@@ -17,16 +16,22 @@ import {
17
16
  writeJsonFile,
18
17
  } from "../config.mjs";
19
18
  import {
19
+ getInstallerVersion,
20
+ isDanglingSymlink,
21
+ markerIsStale,
22
+ readInstallerMarker,
20
23
  resolveHooksRoot,
21
24
  resolveSkillSource,
22
25
  removePath,
23
26
  symlinkOrCopy,
27
+ writeInstallerMarker,
24
28
  } from "./common.mjs";
25
29
 
26
30
  const SKILLS = [{ name: "meko-mcp-tools" }];
27
31
 
28
32
  const HOOKS = [
29
- { event: "sessionStart", script: "session-start.sh", timeout: 15000 },
33
+ { event: "sessionStart", script: "cursor-session-start.sh", timeout: 15000 },
34
+ { event: "beforeSubmitPrompt", script: "before-submit-prompt.sh", matcher: "UserPromptSubmit", timeout: 2000 },
30
35
  { event: "preCompact", script: "pre-compact.sh" },
31
36
  { event: "sessionEnd", script: "session-end.sh", timeout: 30000 },
32
37
  ];
@@ -98,28 +103,30 @@ export function createCursorAdapter(customPaths = {}) {
98
103
  writeJsonFile(mcpPath, cfg);
99
104
  },
100
105
  setHookEnvironment({ url, apiKey, dryRun, log, scope, projectRoot, cursorHookMode }) {
101
- const mode = cursorHookMode || "hybrid";
106
+ const mode = cursorHookMode || "native";
102
107
  if (dryRun) {
103
- if (mode === "native" || mode === "hybrid") {
108
+ if (mode === "native") {
104
109
  log.dim(` Would configure Cursor hooks at ${paths.getHooksPath(scope, projectRoot)}`);
105
110
  }
106
- if (mode === "claude-compat" || mode === "hybrid") {
111
+ if (mode === "claude-compat") {
107
112
  log.dim(` Would configure Claude-compatible hooks at ${paths.getClaudeCompatSettingsPath(scope, projectRoot)}`);
108
113
  }
109
114
  return;
110
115
  }
111
116
 
112
- if (mode === "native" || mode === "hybrid") {
117
+ if (mode === "native") {
113
118
  installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths });
114
119
  }
115
- if (mode === "claude-compat" || mode === "hybrid") {
120
+ if (mode === "claude-compat") {
116
121
  installClaudeCompatHooks({ scope, projectRoot, url, apiKey, paths });
117
122
  }
118
123
  },
119
124
  installSkills({ dryRun, log, scope, projectRoot }) {
120
125
  const skillDir = paths.getSkillsDir(scope, projectRoot);
126
+ const currentVersion = getInstallerVersion();
121
127
  if (dryRun) {
122
128
  log.dim(` Would install skills into ${skillDir}`);
129
+ log.dim(` Would write .meko-installer marker (version=${currentVersion})`);
123
130
  return { marketplace: "skipped", skills: "dry-run" };
124
131
  }
125
132
 
@@ -127,10 +134,51 @@ export function createCursorAdapter(customPaths = {}) {
127
134
  for (const skill of SKILLS) {
128
135
  const source = resolveSkillSource(skill.name);
129
136
  const destination = join(skillDir, skill.name);
130
- if (existsSync(destination)) continue;
137
+
138
+ if (existsSync(destination)) {
139
+ // Symlinks pointing at our own source directory are already live —
140
+ // the source tree IS the latest content, so there's nothing to
141
+ // refresh. Treat them the same as a fresh-current-version install.
142
+ if (isOwnSymlink(destination, source)) continue;
143
+
144
+ const marker = readInstallerMarker(destination);
145
+ if (!marker) {
146
+ // No marker AND not a symlink we own — user-managed dir or a
147
+ // pre-marker install. Leave it alone to avoid clobbering local
148
+ // edits; the user can delete it themselves for a fresh deploy.
149
+ log.dim(
150
+ ` Skipping ${destination}: no .meko-installer marker (user-managed).`,
151
+ );
152
+ continue;
153
+ }
154
+ if (markerIsStale(destination, currentVersion)) {
155
+ const from = marker.version ?? "pre-1.0.9";
156
+ log.dim(
157
+ ` Upgrading cursor skill from ${from} -> ${currentVersion}`,
158
+ );
159
+ removePath(destination);
160
+ } else {
161
+ // Same version already deployed — nothing to do.
162
+ continue;
163
+ }
164
+ } else if (isDanglingSymlink(destination)) {
165
+ // Stale symlink left behind by an earlier install whose target
166
+ // (e.g. a deleted worktree) no longer exists. existsSync is false
167
+ // for these, so the branch above misses them. Clear it so the
168
+ // fresh-install path below can replace it cleanly.
169
+ log.dim?.(
170
+ ` Replacing dangling symlink at ${destination} (target missing).`,
171
+ );
172
+ removePath(destination);
173
+ }
174
+
131
175
  const strategy = symlinkOrCopy(source, destination);
176
+ // Only stamp a marker when we materialized a real copy. Writing into
177
+ // a symlinked source dir would mutate the shipped asset tree, which
178
+ // is confusing at best and a permission error at worst. Symlinks
179
+ // re-resolve via isOwnSymlink on the next run.
132
180
  if (strategy === "copy") {
133
- writeFileSync(join(destination, ".meko-installer"), "managed\n", "utf8");
181
+ writeInstallerMarker(destination);
134
182
  }
135
183
  }
136
184
 
@@ -149,7 +197,7 @@ export function createCursorAdapter(customPaths = {}) {
149
197
  uninstallCursorHooks({
150
198
  scope,
151
199
  projectRoot,
152
- mode: cursorHookMode || "hybrid",
200
+ mode: cursorHookMode || "native",
153
201
  paths,
154
202
  });
155
203
  results.push({ key: "hooks", ok: true, message: "Cursor hook configuration cleaned up." });
@@ -206,10 +254,12 @@ function installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths }) {
206
254
  }
207
255
 
208
256
  const handlersRoot = resolveHooksRoot();
257
+ pruneNativeCursorMekoHooks(cfg.hooks);
209
258
  for (const hook of HOOKS) {
210
259
  const scriptPath = join(handlersRoot, hook.script);
211
260
  const entry = { command: buildScriptCommand({ scriptPath }) };
212
261
  if (hook.timeout) entry.timeout = hook.timeout;
262
+ if (hook.matcher) entry.matcher = hook.matcher;
213
263
  upsertHookEntry(cfg.hooks, hook.event, entry);
214
264
  }
215
265
 
@@ -217,6 +267,20 @@ function installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths }) {
217
267
  writeJsonFile(hooksPath, cfg);
218
268
  }
219
269
 
270
+ // Cursor's claude-compat mode writes absolute-path Meko hook entries into
271
+ // the shared settings.json (on scope=user, the same file Claude Code owns)
272
+ // and de-dupes them via upsertClaudeCompatHook. The Claude Code adapter's
273
+ // pruneMekoHookEntries (issue #60) is narrowed to preserve any entry whose
274
+ // command references the current resolveHooksRoot() — so the entry this
275
+ // function writes survives a subsequent Claude Code install from the
276
+ // SAME install root.
277
+ //
278
+ // TRADE-OFF: if Cursor installed from a different root (e.g. repo clone)
279
+ // than a later Claude Code install (e.g. npx cache), Cursor's entries look
280
+ // byte-identical to the stale Claude Code entries issue #60 targets. They
281
+ // will be pruned; the user must re-run the Cursor installer to restore
282
+ // them. pruneMekoHookEntries emits a warning on any removal so the user
283
+ // has a chance to notice before hooks silently disappear.
220
284
  function installClaudeCompatHooks({ scope, projectRoot, url, apiKey, paths }) {
221
285
  const settingsPath = paths.getClaudeCompatSettingsPath(scope, projectRoot);
222
286
  const cfg = readJsonFile(settingsPath);
@@ -254,6 +318,30 @@ function upsertHookEntry(hooksObj, event, entry) {
254
318
  if (!exists) hooksObj[event].push(entry);
255
319
  }
256
320
 
321
+ function pruneNativeCursorMekoHooks(hooksObj) {
322
+ if (!hooksObj || typeof hooksObj !== "object") return;
323
+ for (const event of Object.keys(hooksObj)) {
324
+ if (!Array.isArray(hooksObj[event])) continue;
325
+ hooksObj[event] = hooksObj[event].filter((item) => {
326
+ if (!item?.command) return true;
327
+ return !isMekoHookCommand(item.command);
328
+ });
329
+ }
330
+ }
331
+
332
+ function isMekoHookCommand(command) {
333
+ if (typeof command !== "string") return false;
334
+ if (!command.includes("hooks-handlers/")) return false;
335
+ return [
336
+ "session-start.sh",
337
+ "cursor-session-start.sh",
338
+ "before-submit-prompt.sh",
339
+ "pre-compact.sh",
340
+ "session-end.sh",
341
+ "post-tool-context.sh",
342
+ ].some((script) => command.includes(script));
343
+ }
344
+
257
345
  function upsertClaudeCompatHook(hooksObj, event, entry) {
258
346
  if (!Array.isArray(hooksObj[event])) hooksObj[event] = [];
259
347
  let bucket = hooksObj[event].find((item) => Array.isArray(item.hooks));
@@ -267,10 +355,10 @@ function upsertClaudeCompatHook(hooksObj, event, entry) {
267
355
 
268
356
  function uninstallCursorHooks({ scope, projectRoot, mode, paths }) {
269
357
  const handlersRoot = resolveHooksRoot();
270
- const scripts = ["session-start.sh", "pre-compact.sh", "session-end.sh"]
358
+ const scripts = ["session-start.sh", "cursor-session-start.sh", "before-submit-prompt.sh", "pre-compact.sh", "session-end.sh"]
271
359
  .map((name) => join(handlersRoot, name));
272
360
 
273
- if (mode === "native" || mode === "hybrid") {
361
+ if (mode === "native") {
274
362
  const hooksPath = paths.getHooksPath(scope, projectRoot);
275
363
  const cfg = readJsonFile(hooksPath);
276
364
  if (cfg?.hooks && typeof cfg.hooks === "object") {
@@ -285,7 +373,7 @@ function uninstallCursorHooks({ scope, projectRoot, mode, paths }) {
285
373
  }
286
374
  }
287
375
 
288
- if (mode === "claude-compat" || mode === "hybrid") {
376
+ if (mode === "claude-compat") {
289
377
  // NOTE: we deliberately do NOT strip env.MEKO_MCP_URL / env.MEKO_API_KEY here.
290
378
  // On scope=user, this file is the same ~/.claude/settings.json the Claude Code
291
379
  // adapter owns; the Claude Code uninstall (claude-code.mjs removeKeys call) is
@@ -314,6 +402,23 @@ function uninstallCursorHooks({ scope, projectRoot, mode, paths }) {
314
402
  }
315
403
  }
316
404
 
405
+ /**
406
+ * True when `destination` is a symlink that resolves to `expectedSource`.
407
+ * Used to recognize in-place symlink installs on re-run: the live source is
408
+ * the latest content, so there's nothing to refresh.
409
+ */
410
+ function isOwnSymlink(destination, expectedSource) {
411
+ try {
412
+ const stat = lstatSync(destination);
413
+ if (!stat.isSymbolicLink()) return false;
414
+ const linkTarget = readlinkSync(destination);
415
+ const resolvedTarget = resolve(dirname(destination), linkTarget);
416
+ return resolvedTarget === expectedSource;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
317
422
  function uninstallCursorSkills({ scope, projectRoot, paths }) {
318
423
  const skillsDir = paths.getSkillsDir(scope, projectRoot);
319
424
 
@@ -1,5 +1,6 @@
1
1
  import { createClaudeCodeAdapter } from "./claude-code.mjs";
2
2
  import { createClaudeDesktopAdapter } from "./claude-desktop.mjs";
3
+ import { createCodexAdapter } from "./codex.mjs";
3
4
  import { createCursorAdapter } from "./cursor.mjs";
4
5
 
5
6
  /**
@@ -10,5 +11,6 @@ export function getClientAdapters() {
10
11
  "claude-code": createClaudeCodeAdapter(),
11
12
  "claude-desktop": createClaudeDesktopAdapter(),
12
13
  cursor: createCursorAdapter(),
14
+ codex: createCodexAdapter(),
13
15
  };
14
16
  }
package/lib/config.mjs CHANGED
@@ -96,6 +96,62 @@ export function getClaudeCompatSettingsPath(scope, projectRoot) {
96
96
  return getClaudeCodeSettingsPath();
97
97
  }
98
98
 
99
+ /**
100
+ * Return path to Codex's user-level config (TOML).
101
+ * Honors $CODEX_HOME if set, else defaults to ~/.codex/config.toml.
102
+ * Codex has no project-level config — caller should warn and degrade
103
+ * if a project scope was requested.
104
+ * @returns {string}
105
+ */
106
+ export function getCodexConfigPath() {
107
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
108
+ return join(codexHome, "config.toml");
109
+ }
110
+
111
+ /**
112
+ * Return path to Codex's user-level AGENTS.md.
113
+ * @returns {string}
114
+ */
115
+ export function getCodexAgentsMdPath() {
116
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
117
+ return join(codexHome, "AGENTS.md");
118
+ }
119
+
120
+ /**
121
+ * Return path to Codex's user-level hooks config.
122
+ * @returns {string}
123
+ */
124
+ export function getCodexHooksPath() {
125
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
126
+ return join(codexHome, "hooks.json");
127
+ }
128
+
129
+ /**
130
+ * Return path to the Meko env file the Codex hooks source on launch.
131
+ * Lives next to hooks.json under $CODEX_HOME (default ~/.codex/) and is
132
+ * written at mode 0600. Used because Codex's hooks.json schema has no
133
+ * `env` block, and inlining the API key into each hook's `command`
134
+ * field would expose it via `ps -ef` while the hook runs.
135
+ *
136
+ * The hook command becomes `. ~/.codex/meko-env.sh && bash /path/to/hook.sh`,
137
+ * so the secret never appears in process arguments.
138
+ *
139
+ * @returns {string}
140
+ */
141
+ export function getCodexMekoEnvFilePath() {
142
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
143
+ return join(codexHome, "meko-env.sh");
144
+ }
145
+
146
+ /**
147
+ * Return path to the shared agent-skills directory used by Codex
148
+ * (and other agent runtimes that follow the convention).
149
+ * @returns {string}
150
+ */
151
+ export function getCodexSkillsRoot() {
152
+ return join(homedir(), ".agents", "skills");
153
+ }
154
+
99
155
  // ---------------------------------------------------------------------------
100
156
  // Read / Write primitives
101
157
  // ---------------------------------------------------------------------------
package/lib/detect.mjs CHANGED
@@ -8,6 +8,10 @@
8
8
  */
9
9
 
10
10
  import { execSync } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { readJsonFile } from "./config.mjs";
11
15
 
12
16
  // ---------------------------------------------------------------------------
13
17
  // Internal helpers
@@ -70,56 +74,60 @@ export function detectClaude() {
70
74
  // detectExistingMeko
71
75
  // ---------------------------------------------------------------------------
72
76
 
77
+ /**
78
+ * Canonical location of Claude Code's user-scope MCP registry. This is the
79
+ * file `claude mcp add` writes to — we read it directly rather than shelling
80
+ * out to `claude mcp list`, which takes 18–28s on real user machines and
81
+ * was timing out against our 10s guard (issue #80).
82
+ *
83
+ * @returns {string}
84
+ */
85
+ export function getClaudeCodeConfigPath() {
86
+ return join(homedir(), ".claude.json");
87
+ }
88
+
73
89
  /**
74
90
  * Check if a Meko MCP server is already registered in Claude Code.
75
91
  *
76
- * Runs `claude mcp list` and scans the output for the given server name.
77
- * The output format may vary between Claude CLI versions; we look for a
78
- * line that starts with `<serverName>:` (e.g. "meko: http://... - Connected")
79
- * and extract a status keyword from it.
92
+ * Reads `~/.claude.json` directly and inspects either `mcpServers[serverName]`
93
+ * (user scope) or `projects[projectRoot].mcpServers[serverName]` (project
94
+ * scope). This mirrors the approach the Claude Desktop adapter already uses
95
+ * against its own config (installer/lib/clients/claude-desktop.mjs:detectExisting)
96
+ * and replaces the previous `claude mcp list` shell-out, which silently
97
+ * returned `found: false` whenever the CLI exceeded the 10s run() timeout.
98
+ *
99
+ * Scope handling: `claude mcp add --scope project` writes under
100
+ * `projects[<absolute projectRoot>].mcpServers`, not the top-level
101
+ * `mcpServers`. A project-scope re-install must detect the existing entry
102
+ * in that nested location, otherwise the overwrite prompt is skipped and
103
+ * `claude mcp add --scope project` rejects the duplicate with "already
104
+ * exists". User-scope installs read the top-level `mcpServers` object
105
+ * (which is where the CLI writes entries added with `--scope user`).
80
106
  *
81
107
  * @param {string} [serverName="meko"] - The MCP server name to look for.
108
+ * @param {{ configPath?: string, scope?: "user"|"project", projectRoot?: string }} [options]
109
+ * `configPath` — test-only injection point for the config file path; defaults to `~/.claude.json`.
110
+ * `scope` — "user" (default) or "project". Project scope looks under `projects[projectRoot].mcpServers`.
111
+ * `projectRoot` — absolute path used as the key into `projects[...]` when `scope === "project"`.
112
+ * Ignored for user scope. Must be supplied by the caller for project scope.
82
113
  * @returns {{ found: boolean, status: string }}
83
- * `found` - true if a line matching `serverName` was found.
84
- * `status` - the status value from that line (e.g. "Connected", "Failed"),
85
- * or "" if not found.
114
+ * `found` - true if a matching MCP entry exists at the scope-appropriate location.
115
+ * `status` - "Configured" when found, "" otherwise. (Kept as a string for
116
+ * API compatibility with the prior implementation; callers use
117
+ * this only for logging, never parsing.)
86
118
  */
87
- export function detectExistingMeko(serverName = "meko") {
88
- const result = { found: false, status: "" };
89
-
90
- const output = run("claude mcp list");
91
- if (!output) return result;
92
-
93
- // Scan each line for one that begins with the server name.
94
- const lines = output.split("\n");
95
- for (const line of lines) {
96
- const trimmed = line.trim();
97
-
98
- // Match "serverName:" prefix (colon-delimited format) or a table row
99
- // where the first whitespace-separated token equals the server name.
100
- const isMatch =
101
- trimmed.startsWith(`${serverName}:`) ||
102
- trimmed.split(/\s+/)[0] === serverName;
103
-
104
- if (!isMatch) continue;
105
-
106
- result.found = true;
107
-
108
- // Extract status from known keywords anywhere on the line.
109
- if (/\bConnected\b/i.test(trimmed)) {
110
- result.status = "Connected";
111
- } else if (/\bFailed\b/i.test(trimmed)) {
112
- result.status = "Failed";
113
- } else if (/\bError\b/i.test(trimmed)) {
114
- result.status = "Error";
115
- } else if (/\bDisconnected\b/i.test(trimmed)) {
116
- result.status = "Disconnected";
117
- } else {
118
- result.status = "Unknown";
119
- }
120
-
121
- return result;
119
+ export function detectExistingMeko(serverName = "meko", options = {}) {
120
+ const { scope = "user", projectRoot } = options;
121
+ const configPath = options.configPath ?? getClaudeCodeConfigPath();
122
+ const cfg = readJsonFile(configPath);
123
+
124
+ const entry =
125
+ scope === "project"
126
+ ? cfg?.projects?.[projectRoot]?.mcpServers?.[serverName]
127
+ : cfg?.mcpServers?.[serverName];
128
+
129
+ if (!entry) {
130
+ return { found: false, status: "" };
122
131
  }
123
-
124
- return result;
132
+ return { found: true, status: "Configured" };
125
133
  }