@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.
- package/README.md +242 -44
- package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
- package/assets/hooks-handlers/cursor-session-start.sh +42 -0
- package/assets/hooks-handlers/lib/capture.js +457 -107
- package/assets/hooks-handlers/lib/package.json +1 -0
- package/assets/hooks-handlers/test_single_conversation.sh +1 -1
- package/assets/scripts/package-desktop-skill.sh +36 -0
- package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
- package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
- package/bin/create-meko-setup.mjs +151 -22
- package/lib/clients/claude-code.mjs +378 -68
- package/lib/clients/claude-desktop.mjs +45 -8
- package/lib/clients/codex.mjs +1209 -0
- package/lib/clients/common.mjs +223 -1
- package/lib/clients/cursor.mjs +118 -13
- package/lib/clients/index.mjs +2 -0
- package/lib/config.mjs +56 -0
- package/lib/detect.mjs +52 -44
- package/lib/installer.mjs +11 -4
- package/lib/mcp-transport.mjs +61 -2
- package/lib/migrate/adapters/memory-md.mjs +13 -6
- package/lib/migrate/datapack.mjs +167 -0
- package/lib/migrate/id.mjs +127 -9
- package/lib/migrate/orchestrator.mjs +68 -10
- package/lib/migrate.mjs +346 -3
- package/lib/prompt.mjs +22 -4
- package/lib/validate.mjs +313 -9
- package/lib/version-check.mjs +178 -0
- package/package.json +2 -1
- package/scripts/bundle-assets.mjs +8 -0
package/lib/clients/common.mjs
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
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
|
+
}
|
package/lib/clients/cursor.mjs
CHANGED
|
@@ -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 || "
|
|
106
|
+
const mode = cursorHookMode || "native";
|
|
102
107
|
if (dryRun) {
|
|
103
|
-
if (mode === "native"
|
|
108
|
+
if (mode === "native") {
|
|
104
109
|
log.dim(` Would configure Cursor hooks at ${paths.getHooksPath(scope, projectRoot)}`);
|
|
105
110
|
}
|
|
106
|
-
if (mode === "claude-compat"
|
|
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"
|
|
117
|
+
if (mode === "native") {
|
|
113
118
|
installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths });
|
|
114
119
|
}
|
|
115
|
-
if (mode === "claude-compat"
|
|
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
|
-
|
|
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
|
-
|
|
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 || "
|
|
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"
|
|
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"
|
|
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
|
|
package/lib/clients/index.mjs
CHANGED
|
@@ -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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
84
|
-
* `status` -
|
|
85
|
-
*
|
|
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
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|