@yugabytedb/meko-mcp 1.1.0 → 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 (26) hide show
  1. package/README.md +68 -19
  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 +291 -40
  5. package/assets/scripts/package-desktop-skill.sh +36 -0
  6. package/assets/skills/meko-mcp-tools/references/tools-overview.md +1 -1
  7. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
  8. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
  9. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  10. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  11. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
  12. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
  13. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
  14. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
  15. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
  16. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
  17. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
  18. package/bin/create-meko-setup.mjs +4 -4
  19. package/lib/clients/claude-desktop.mjs +11 -8
  20. package/lib/clients/codex.mjs +236 -4
  21. package/lib/clients/common.mjs +81 -0
  22. package/lib/clients/cursor.mjs +47 -10
  23. package/lib/config.mjs +26 -0
  24. package/lib/installer.mjs +2 -2
  25. package/package.json +1 -1
  26. package/scripts/bundle-assets.mjs +8 -0
@@ -0,0 +1,155 @@
1
+ # Troubleshooting — Error Recovery and Retry Strategies
2
+
3
+ These patterns are extracted from real agent sessions. Follow them to avoid wasting tool calls and context window on repeated failures.
4
+
5
+ ## General rules
6
+
7
+ 1. **Never retry the identical failed call more than once.** If it fails twice with the same error, it's not transient — diagnose the cause.
8
+ 2. **Distinguish transient vs persistent failures.** "Connection already closed" is transient (retry once). "Permission denied" is persistent (stop, don't retry).
9
+ 3. **Don't guess parameters sequentially.** If a tool fails with one parameter format, don't try 4 variations. Check this skill's docs for the correct format first.
10
+ 4. **Use your session's `agent_id` consistently.** For Claude Desktop the default is `claude_desktop`; for cross-project common facts use `meko_agent`. Other clients use `<client>:<repo-basename>` (e.g. `claude_code:meko-mcp-server`). Use `agent_id="meko_agent"` deliberately on `memory_search`/`memory_get_all` only when you want the common bucket — empty string also resolves to `meko_agent`, not a cross-agent fan-out. Don't switch between forms mid-session.
11
+
12
+ ---
13
+
14
+ ## Connection errors: "connection already closed"
15
+
16
+ **Affects:** Memory tools (`memory_add`, `memory_search`, `memory_get_all`) most frequently. DB tools less often.
17
+
18
+ **Why it happens:** The Python memory subsystem uses a connection cache that doesn't recover from dropped connections (unlike the Go API server which has retry logic with 5 retries and 3-second delays).
19
+
20
+ **What to do:**
21
+
22
+ 1. Retry the same call **once**.
23
+ 2. If it fails again, the memory subsystem is unhealthy for this session — stop trying memory calls and tell the user. Do not waste tokens re-sending the same payload.
24
+ 3. Direct-SQL fallback **does not apply** on cloud Free Tier (`db_run_read_only_query` returns `{"error": "not_available", "detail": "Free Tier: DB Tools access is not enabled. coming soon."}`). On self-hosted / paid deployments where DB tools are available, inspect memory rows via the multi-tenant schema — tables are namespaced by datapack, not by agent_id. The old `{agent_id}_mem0_collection` per-agent-table layout is gone.
25
+
26
+ **Anti-pattern from real sessions:** Agent called `memory_add` with 500+ tokens of text, got "connection already closed", retried the identical call 3 more times (each 500+ tokens). Wasted ~2000 tokens on guaranteed failures.
27
+
28
+ ---
29
+
30
+ ## RAG pipeline stuck at INIT
31
+
32
+ **Affects:** `knowledgebase_trigger_index_creation` — the build triggers but never completes.
33
+
34
+ **Symptoms:**
35
+ - `knowledgebase_check_index_status` returns empty `pipeline_details` and `pipeline_stats`
36
+ - Documents remain at `INIT` status indefinitely
37
+ - Work queue tasks are `IN_PROGRESS` with `lease_expires_at` in the past
38
+
39
+ **Diagnosis:** Query the work queue directly:
40
+ ```
41
+ db_run_read_only_query(scope="read",
42
+ query="SELECT task_type, status, lease_expires_at FROM dist_rag.work_queue WHERE index_name = 'your_index' ORDER BY created_at DESC LIMIT 10")
43
+ ```
44
+
45
+ Look for tasks stuck `IN_PROGRESS` with expired leases — these indicate crashed or hung workers.
46
+
47
+ **What to do:**
48
+
49
+ 1. Poll status **at most 3 times** with 1-2 minute intervals
50
+ 2. If no progress after 3 checks, **stop polling** — the pipeline is stuck
51
+ 3. There is **no way to reset or retry stuck tasks via MCP tools**
52
+ 4. Creating a new index with a different name (e.g., `_v2`) is a workaround, but if the workers themselves are down, this will also get stuck
53
+ 5. Recommend the user check worker logs or restart the pipeline service
54
+
55
+ **Anti-pattern from real sessions:** Agent created indexes `v1`, `v2`, `v3` — all stuck with the same symptoms. Even making the S3 bucket public didn't help. The issue was worker infrastructure, not permissions.
56
+
57
+ ---
58
+
59
+ ## db_run_write_query guardrail error
60
+
61
+ **Error:** `'dict' object has no attribute 'guardrail_config'`
62
+
63
+ **Affects:** `db_run_write_query` in some deployments.
64
+
65
+ **What to do:**
66
+
67
+ 1. Try the call once. If it fails with this specific error, write operations are unavailable in this deployment.
68
+ 2. For reads, always use `db_run_read_only_query` (never `db_run_write_query` for SELECT).
69
+ 3. Report the issue — it's a server-side bug, not a parameter problem.
70
+
71
+ ---
72
+
73
+ ## knowledgebase_add_source_to_index failure
74
+
75
+ **Error shape:** `{"error": "not_available", "detail": "Vector index creation is not available on the free tier."}`
76
+
77
+ You're on cloud Free Tier; the ingestion tools are gated off. There is no retry that will succeed. Point the user at the Meko UI's **Add Knowledge** file-upload flow (Datapack → Actions → Add Knowledge) instead.
78
+
79
+ **Error shape:** `{"error": "index_not_found", "detail": "No vector index found with name '...'"}`
80
+
81
+ The `index_name` doesn't exist in this datapack's `dist_rag.vector_indexes` table. Either you're targeting the wrong datapack (check `datapack_id`), or the index hasn't been created yet — call `knowledgebase_trigger_index_creation` first.
82
+
83
+ **Error shape:** tool returns `"build_triggered"` but `knowledgebase_check_index_status` shows documents stuck at `INIT` for >5 minutes.
84
+
85
+ Most common cause on self-hosted deployments: the worker can't reach the S3 bucket. `S3BucketCrawler()` uses boto3's default credential chain — check `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / IAM role / `~/.aws/credentials` on the worker host, not on the MCP server. Second cause: the URI isn't `s3://`-prefixed (`aws_source_processor.py` rejects other schemes with `ValueError("Invalid source URI")` async).
86
+
87
+ **Historical note:** the old `r_index_name` vs `r_index_id` parameter-mismatch bug is fixed on main — the MCP tool now resolves `index_name → index_id` internally via a `SELECT id FROM dist_rag.vector_indexes WHERE index_name = %s` step. Older docs referencing this bug are stale.
88
+
89
+ ---
90
+
91
+ ## Scope parameter errors
92
+
93
+ **Error:** `Insufficient scope: 'all'. This tool requires 'read' or higher.`
94
+
95
+ The only valid scope values are: `"read"`, `"write"`, `"admin"`.
96
+
97
+ - Use `"read"` for all read operations (default choice)
98
+ - Use `"write"` for inserts, updates, creates, memory writes
99
+ - Use `"admin"` only for destructive deletes
100
+
101
+ **Never use:** `"all"`, `"readwrite"`, `"rw"`, or any other value.
102
+
103
+ ---
104
+
105
+ ## agent_id errors
106
+
107
+ On the Cloud multi-tenant schema, `agent_id` is a TEXT column value — not a PostgreSQL identifier — so arbitrary strings are accepted.
108
+
109
+ `agent_id` is **not a constant**. Cloud Meko supports multiple agents on one datapack, and the Cloud UI renders every `agent_id` as a badge on each row regardless of value. The canonical shape for new writes is `<client>:<repo-basename>` for coding agents (e.g. `claude_code:meko-mcp-server`), a bare client name for non-coding clients like Claude Desktop (`claude_desktop`), or `meko_agent` for the cross-project common bucket — see `tools-agent-id-conventions.md`.
110
+
111
+ ### "My writes are landing in `meko_agent` instead of my agent's bucket"
112
+
113
+ If you call `memory_add` with empty / None / whitespace `agent_id`, the server does NOT reject the write. It silently resolves the value to `meko_agent` (the cross-project common bucket) and the row lands there. The Cloud UI then shows a `meko_agent` badge instead of your client's badge.
114
+
115
+ Fix: always pass a concrete non-empty `agent_id`. For Claude Desktop use `agent_id="claude_desktop"`; for hook-driven clients (Claude Code, Cursor) fetch the value the SessionStart hook injected into `additionalContext` and pass it verbatim. Use `agent_id="meko_agent"` only when you genuinely want a fact in the cross-project pool (user identity, global preferences).
116
+
117
+ ### "My reads return fewer results than I expected"
118
+
119
+ `memory_search` and `memory_get_all` filter strictly on your passed `agent_id`. A memory written by `claude-code:<slug-A>` is invisible to a search with `agent_id="cursor:<slug-B>"`. If you need a broader view, call repeatedly across the agent_ids you want to cover, or point the user at the UI's Memory Summary page (which shows every row regardless of agent_id).
120
+
121
+ ### "I'm seeing rows tagged with legacy agent_id values"
122
+
123
+ Pre-existing data may be tagged `"agent"`, `"claude_code"`, `"cursor:<slug>"`, or similar ad-hoc values. All remain readable. New writes should follow the canonical `<client>:<project-slug>` form; don't try to retag old rows.
124
+
125
+ ---
126
+
127
+ ## Permission denied on data tables
128
+
129
+ **Error:** `permission denied for table customer_complaints`
130
+
131
+ **Why:** The MCP database user may not have read access to all tables, particularly data tables created by the provisioning system within a datapack.
132
+
133
+ **What to do:**
134
+
135
+ 1. This is a server-side configuration issue — not fixable via MCP tools.
136
+ 2. Don't retry. Don't try different query formulations.
137
+ 3. Use `db_summarize_database` to see which tables you CAN access.
138
+ 4. Report the permission gap to the Meko admin.
139
+
140
+ ---
141
+
142
+ ## Safe-to-retry vs not
143
+
144
+ | Tool | Safe to retry? | Why |
145
+ |------|---------------|-----|
146
+ | All `read` scope tools | Yes | Reads are idempotent |
147
+ | `memory_add` | Yes (once) | Mem0 has dedup logic |
148
+ | `memory_update` | Yes (once) | Overwrites same ID |
149
+ | `memory_delete_by_id` | Yes | Deleting already-deleted is a no-op |
150
+ | `db_run_write_query` (INSERT) | **No** | May double-insert rows |
151
+ | `db_run_write_query` (CREATE TABLE) | **No** | "Table already exists" error |
152
+ | `knowledgebase_trigger_index_creation` | **No** | Creates duplicate indexes |
153
+ | `conversation_create` | **No** | Creates duplicate conversations |
154
+ | `conversation_add_message` with `seed` | Yes | Seed-based dedup prevents duplicates |
155
+ | `conversation_add_message` without `seed` | **No** | Creates duplicate messages |
@@ -25,7 +25,7 @@ const options = {
25
25
  scope: { type: 'string', default: 'user' },
26
26
  name: { type: 'string', default: 'meko' },
27
27
  client: { type: 'string', default: 'claude-code' },
28
- 'cursor-hook-mode': { type: 'string', default: 'hybrid' },
28
+ 'cursor-hook-mode': { type: 'string', default: 'native' },
29
29
  'skip-skills': { type: 'boolean', default: false },
30
30
  'skip-validation': { type: 'boolean', default: false },
31
31
  'no-canary': { type: 'boolean', default: false },
@@ -109,7 +109,7 @@ Options:
109
109
  codex always installs at user scope (no project-level
110
110
  config); --scope=project degrades with a warning.
111
111
  --name <name> MCP server name in config (default: meko)
112
- --cursor-hook-mode Cursor hook mode: native, claude-compat, hybrid (default: hybrid)
112
+ --cursor-hook-mode Cursor hook mode: native or claude-compat (default: native)
113
113
  --skip-skills Skip plugin and skills installation
114
114
  --skip-validation Skip post-install validation
115
115
  --no-canary Skip the canary datapack_list call. The
@@ -218,7 +218,7 @@ function detectInstalledClients() {
218
218
  }
219
219
 
220
220
  function isValidCursorHookMode(mode) {
221
- return ['native', 'claude-compat', 'hybrid'].includes(mode);
221
+ return ['native', 'claude-compat'].includes(mode);
222
222
  }
223
223
 
224
224
  /**
@@ -391,7 +391,7 @@ async function main() {
391
391
  process.exit(0);
392
392
  }
393
393
  if (!isValidCursorHookMode(flags['cursor-hook-mode'])) {
394
- error(`Unknown --cursor-hook-mode "${flags['cursor-hook-mode']}". Valid values: native, claude-compat, hybrid.`);
394
+ error(`Unknown --cursor-hook-mode "${flags['cursor-hook-mode']}". Valid values: native, claude-compat.`);
395
395
  process.exit(1);
396
396
  }
397
397
 
@@ -9,8 +9,8 @@ import {
9
9
  removeKeys,
10
10
  } from "../config.mjs";
11
11
  import {
12
+ getAssetRoot,
12
13
  getInstallerVersion,
13
- getRepoRoot,
14
14
  markerIsStale,
15
15
  readInstallerMarker,
16
16
  run,
@@ -39,13 +39,16 @@ import {
39
39
  * mcp-remote) with no space around the colon after the header name —
40
40
  * some clients mis-parse space-containing `--header` args.
41
41
  *
42
- * installSkills() runs skills/scripts/package-desktop-skill.sh to build the
43
- * .skill bundle and prints import instructions. Desktop has no programmatic
44
- * skill install API — the user drags the .skill into the app.
42
+ * installSkills() runs scripts/package-desktop-skill.sh under the resolved
43
+ * asset root to build the .skill bundle and prints import instructions.
44
+ * Desktop has no programmatic skill install API — the user drags the .skill
45
+ * into the app. The asset root is `installer/assets/` for packaged installs
46
+ * (npx) and `<repo>/skills/` for source checkouts; both layouts share the
47
+ * same shape so the script can be invoked the same way.
45
48
  */
46
49
  export function createClaudeDesktopAdapter(options = {}) {
47
50
  const configPathResolver = options.getConfigPath ?? getClaudeDesktopConfigPath;
48
- const repoRootResolver = options.getRepoRoot ?? getRepoRoot;
51
+ const assetRootResolver = options.getAssetRoot ?? getAssetRoot;
49
52
  const packageRunner = options.runPackageScript ?? defaultRunPackageScript;
50
53
  const npxProber = options.probeNpx ?? defaultProbeNpx;
51
54
 
@@ -126,9 +129,9 @@ export function createClaudeDesktopAdapter(options = {}) {
126
129
  }
127
130
  },
128
131
  installSkills({ dryRun, log }) {
129
- const repoRoot = repoRootResolver();
130
- const script = join(repoRoot, "skills", "scripts", "package-desktop-skill.sh");
131
- const output = join(repoRoot, "skills", "meko-mcp-tools-desktop.skill");
132
+ const assetRoot = assetRootResolver();
133
+ const script = join(assetRoot, "scripts", "package-desktop-skill.sh");
134
+ const output = join(assetRoot, "meko-mcp-tools-desktop.skill");
132
135
  // The .skill output is a ZIP file, so we can't drop a marker inside it.
133
136
  // Stamp the marker in the parent dir instead — it signals which
134
137
  // installer version produced the bundle sitting alongside it, so a
@@ -11,12 +11,14 @@
11
11
  import { execSync } from "node:child_process";
12
12
  import {
13
13
  appendFileSync,
14
+ chmodSync,
14
15
  copyFileSync,
15
16
  existsSync,
16
17
  lstatSync,
17
18
  mkdirSync,
18
19
  readFileSync,
19
20
  readlinkSync,
21
+ rmSync,
20
22
  writeFileSync,
21
23
  } from "node:fs";
22
24
  import { homedir } from "node:os";
@@ -26,13 +28,19 @@ import { askYesNo } from "../prompt.mjs";
26
28
  import {
27
29
  getCodexAgentsMdPath,
28
30
  getCodexConfigPath,
31
+ getCodexHooksPath,
32
+ getCodexMekoEnvFilePath,
29
33
  getCodexSkillsRoot,
34
+ readJsonFile,
35
+ writeJsonFile,
30
36
  } from "../config.mjs";
31
37
  import {
32
38
  getInstallerVersion,
39
+ isDanglingSymlink,
33
40
  markerIsStale,
34
41
  readInstallerMarker,
35
42
  removePath,
43
+ resolveHooksRoot,
36
44
  resolveSkillSource,
37
45
  shellQuote,
38
46
  symlinkOrCopy,
@@ -54,12 +62,24 @@ const RC_FENCE_START = "# >>> meko mcp >>>";
54
62
  const RC_FENCE_END = "# <<< meko mcp <<<";
55
63
 
56
64
  const SKILLS = [{ name: "meko-mcp-tools" }];
65
+ // Codex hook timeouts are in SECONDS (Duration::from_secs in
66
+ // codex-rs/hooks/src/engine/command_runner.rs), not milliseconds like Claude
67
+ // Code / Cursor. Default is 600s if omitted; we cap SessionStart/SessionEnd
68
+ // at 15s/30s to match the Claude Code values logically.
69
+ const MEKO_HOOKS = [
70
+ { event: "SessionStart", script: "session-start.sh", timeout: 15 },
71
+ { event: "PreCompact", script: "pre-compact.sh" },
72
+ { event: "SessionEnd", script: "session-end.sh", timeout: 30 },
73
+ ];
74
+ const MEKO_HOOK_SCRIPTS = MEKO_HOOKS.map((hook) => hook.script);
57
75
 
58
76
  /**
59
77
  * @param {{
60
78
  * getConfigPath?: () => string,
61
79
  * getAgentsMdPath?: () => string,
80
+ * getHooksPath?: () => string,
62
81
  * getSkillsRoot?: () => string,
82
+ * resolveHooksRoot?: () => string,
63
83
  * runCommand?: (cmd: string) => string,
64
84
  * probeCodex?: () => {found: boolean, version: string, path: string},
65
85
  * probeCodexApp?: () => {found: boolean, path: string, platform: string, version: string} | null,
@@ -71,6 +91,10 @@ const SKILLS = [{ name: "meko-mcp-tools" }];
71
91
  export function createCodexAdapter(options = {}) {
72
92
  const resolveConfigPath = options.getConfigPath ?? getCodexConfigPath;
73
93
  const resolveAgentsMdPath = options.getAgentsMdPath ?? getCodexAgentsMdPath;
94
+ const resolveHooksPath = options.getHooksPath ?? getCodexHooksPath;
95
+ const resolveMekoEnvFilePath =
96
+ options.getMekoEnvFilePath ?? getCodexMekoEnvFilePath;
97
+ const resolveHooksRootPath = options.resolveHooksRoot ?? resolveHooksRoot;
74
98
  const resolveSkillsRoot = options.getSkillsRoot ?? getCodexSkillsRoot;
75
99
  const runCommand = options.runCommand ?? defaultRunCommand;
76
100
  const probeCodex = options.probeCodex ?? defaultProbeCodex;
@@ -256,6 +280,12 @@ export function createCodexAdapter(options = {}) {
256
280
  log,
257
281
  });
258
282
  }
283
+ } else if (hasApp && platform === "darwin") {
284
+ setLaunchctlMekoApiKey({
285
+ apiKey,
286
+ runCommand,
287
+ log,
288
+ });
259
289
  }
260
290
  }
261
291
 
@@ -268,10 +298,29 @@ export function createCodexAdapter(options = {}) {
268
298
  if (yes) return true;
269
299
  return promptUser(`${name} MCP is already configured. Overwrite?`, false);
270
300
  },
271
- setHookEnvironment({ log, dryRun }) {
272
- if (!dryRun) {
301
+ setHookEnvironment({ url, apiKey, log, dryRun, verbose }) {
302
+ const hooksPath = resolveHooksPath();
303
+ const hooksRoot = resolveHooksRootPath();
304
+ const envFilePath = resolveMekoEnvFilePath();
305
+ if (dryRun) {
306
+ log?.dim?.(` Would write env file at ${envFilePath} (mode 0600)`);
307
+ log?.dim?.(` Would configure Codex hooks at ${hooksPath}`);
308
+ if (apiKey) {
309
+ log?.dim?.(
310
+ ` MEKO_API_KEY will be stored in ${envFilePath} (mode 0600); hook commands source it (no API key in process args).`,
311
+ );
312
+ }
313
+ return;
314
+ }
315
+
316
+ writeCodexMekoEnvFile(envFilePath, { url, apiKey });
317
+ const changed = upsertCodexMekoHooks(hooksPath, hooksRoot, { envFilePath });
318
+ if (verbose || changed) {
319
+ log?.dim?.(` Updated Codex hooks at ${hooksPath}`);
320
+ }
321
+ if (apiKey) {
273
322
  log?.dim?.(
274
- " Codex does not expose user-installable session hooks; auto-capture is unavailable.",
323
+ ` MEKO_API_KEY stored in ${envFilePath} (mode 0600). Hook commands source the file at run time, so the key never appears in process arguments.`,
275
324
  );
276
325
  }
277
326
  },
@@ -314,6 +363,15 @@ export function createCodexAdapter(options = {}) {
314
363
  } else {
315
364
  continue;
316
365
  }
366
+ } else if (isDanglingSymlink(destination)) {
367
+ // Stale symlink left behind by an earlier install whose target
368
+ // (e.g. a deleted worktree) no longer exists. existsSync is false
369
+ // for these, so the branch above misses them. Clear it so the
370
+ // fresh-install path below can replace it cleanly.
371
+ log.dim?.(
372
+ ` Replacing dangling symlink at ${destination} (target missing).`,
373
+ );
374
+ removePath(destination);
317
375
  }
318
376
 
319
377
  const strategy = symlinkOrCopy(source, destination);
@@ -373,6 +431,25 @@ export function createCodexAdapter(options = {}) {
373
431
  });
374
432
  }
375
433
 
434
+ try {
435
+ const hooksPath = resolveHooksPath();
436
+ const envFilePath = resolveMekoEnvFilePath();
437
+ const changed = removeCodexMekoHooks(hooksPath, { envFilePath });
438
+ if (changed) {
439
+ results.push({
440
+ key: "hooks",
441
+ ok: true,
442
+ message: "Codex Meko hooks cleaned up.",
443
+ });
444
+ }
445
+ } catch (err) {
446
+ results.push({
447
+ key: "hooks",
448
+ ok: false,
449
+ message: `Could not clean Codex hooks: ${err.message}`,
450
+ });
451
+ }
452
+
376
453
  try {
377
454
  const agentsMdPath = resolveAgentsMdPath();
378
455
  const raw = safeReadFile(agentsMdPath);
@@ -434,6 +511,149 @@ export function createCodexAdapter(options = {}) {
434
511
  };
435
512
  }
436
513
 
514
+ // Codex's hooks.json schema only deserializes `hooks` and `state` — sibling
515
+ // keys like `env` are silently dropped, and `HookHandlerConfig::Command` has
516
+ // no per-command `env` field. The runner spawns each command via
517
+ // `$SHELL -lc <command>` with the parent env inherited.
518
+ //
519
+ // We sidestep that by writing a 0600 env file (default ~/.codex/meko-env.sh)
520
+ // that the hook command sources before invoking the script:
521
+ // `. /path/to/meko-env.sh && bash /path/to/hook.sh`
522
+ // The API key never appears in process arguments, so a `ps -ef` from another
523
+ // user (or a logging shim) can't lift it from the command line. Same trust
524
+ // boundary as `git credential-store`.
525
+ function upsertCodexMekoHooks(hooksPath, hooksRoot, { envFilePath } = {}) {
526
+ const cfg = readJsonFile(hooksPath);
527
+ const before = JSON.stringify(cfg);
528
+ if (!cfg.hooks || typeof cfg.hooks !== "object") {
529
+ cfg.hooks = {};
530
+ }
531
+
532
+ pruneCodexMekoHooksObject(cfg.hooks);
533
+ for (const hook of MEKO_HOOKS) {
534
+ const entry = {
535
+ type: "command",
536
+ command: buildCodexHookCommand(join(hooksRoot, hook.script), { envFilePath }),
537
+ };
538
+ if (hook.timeout) entry.timeout = hook.timeout;
539
+ upsertCodexHookEntry(cfg.hooks, hook.event, entry);
540
+ }
541
+
542
+ const after = JSON.stringify(cfg);
543
+ if (after !== before) {
544
+ writeJsonFile(hooksPath, cfg);
545
+ return true;
546
+ }
547
+ return false;
548
+ }
549
+
550
+ function buildCodexHookCommand(scriptPath, { envFilePath } = {}) {
551
+ const parts = [];
552
+ if (envFilePath) parts.push(`. ${shellQuote(envFilePath)}`);
553
+ parts.push(`bash ${shellQuote(scriptPath)}`);
554
+ return parts.join(" && ");
555
+ }
556
+
557
+ /**
558
+ * Write the Meko env file the Codex hooks source. Mode 0600 (user
559
+ * read/write only) so the API key isn't readable by other users.
560
+ * Idempotent: re-running with the same values is a no-op write that
561
+ * still asserts the mode.
562
+ */
563
+ function writeCodexMekoEnvFile(path, { url, apiKey }) {
564
+ mkdirSync(dirname(path), { recursive: true });
565
+ const lines = [
566
+ "# Auto-generated by @yugabytedb/meko-mcp installer.",
567
+ "# Sourced by Codex hooks (see ~/.codex/hooks.json) before each hook runs.",
568
+ "# Mode 0600 — do not commit, do not share, do not relax permissions.",
569
+ `export MEKO_MCP_URL=${shellQuote(url || "")}`,
570
+ `export MEKO_API_KEY=${shellQuote(apiKey || "")}`,
571
+ "",
572
+ ];
573
+ writeFileSync(path, lines.join("\n"), { encoding: "utf8", mode: 0o600 });
574
+ // writeFileSync's `mode` only applies on initial create; re-assert
575
+ // explicitly so a permission relaxation between installs is corrected.
576
+ chmodSync(path, 0o600);
577
+ }
578
+
579
+ function removeCodexMekoHooks(hooksPath, { envFilePath } = {}) {
580
+ const cfg = readJsonFile(hooksPath);
581
+ let changed = false;
582
+ if (cfg.hooks && typeof cfg.hooks === "object") {
583
+ const before = JSON.stringify(cfg);
584
+ pruneCodexMekoHooksObject(cfg.hooks);
585
+ if (Object.keys(cfg.hooks).length === 0) {
586
+ delete cfg.hooks;
587
+ }
588
+ // Older installs (PR 127 first cut) wrote a top-level cfg.env block that
589
+ // Codex silently ignored. Strip it during uninstall so we don't leave a
590
+ // misleading env hanging around in user configs.
591
+ if (cfg.env && typeof cfg.env === "object") {
592
+ delete cfg.env.MEKO_MCP_URL;
593
+ delete cfg.env.MEKO_API_KEY;
594
+ if (Object.keys(cfg.env).length === 0) {
595
+ delete cfg.env;
596
+ }
597
+ }
598
+ if (JSON.stringify(cfg) !== before) {
599
+ writeJsonFile(hooksPath, cfg);
600
+ changed = true;
601
+ }
602
+ }
603
+
604
+ // Remove the meko-env.sh file too — leaving a 0600 file with credentials
605
+ // around after uninstall is a security gap.
606
+ if (envFilePath) {
607
+ try {
608
+ rmSync(envFilePath, { force: true });
609
+ } catch {
610
+ // best-effort
611
+ }
612
+ }
613
+ return changed;
614
+ }
615
+
616
+ function upsertCodexHookEntry(hooksObj, event, entry) {
617
+ if (!Array.isArray(hooksObj[event])) hooksObj[event] = [];
618
+ let group = hooksObj[event].find((item) => Array.isArray(item?.hooks));
619
+ if (!group) {
620
+ group = { hooks: [] };
621
+ hooksObj[event].push(group);
622
+ }
623
+ const exists = group.hooks.some((hook) => hook.command === entry.command);
624
+ if (!exists) group.hooks.push(entry);
625
+ }
626
+
627
+ function pruneCodexMekoHooksObject(hooksObj) {
628
+ for (const event of Object.keys(hooksObj)) {
629
+ const groups = hooksObj[event];
630
+ if (!Array.isArray(groups)) continue;
631
+ hooksObj[event] = groups
632
+ .map((group) => {
633
+ if (!Array.isArray(group?.hooks)) return group;
634
+ return {
635
+ ...group,
636
+ hooks: group.hooks.filter((hook) => !isMekoHookCommand(hook?.command)),
637
+ };
638
+ })
639
+ .filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
640
+ if (hooksObj[event].length === 0) {
641
+ delete hooksObj[event];
642
+ }
643
+ }
644
+ }
645
+
646
+ function isMekoHookCommand(command) {
647
+ if (typeof command !== "string") return false;
648
+ return MEKO_HOOK_SCRIPTS.some((script) => {
649
+ const escaped = script.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
650
+ const pattern = new RegExp(
651
+ String.raw`(^|[/\\])hooks-handlers[/\\]${escaped}(?=$|['"\s])`,
652
+ );
653
+ return pattern.test(command);
654
+ });
655
+ }
656
+
437
657
  // ---------------------------------------------------------------------------
438
658
  // Probes (default impls; tests inject stubs)
439
659
  // ---------------------------------------------------------------------------
@@ -915,12 +1135,24 @@ export async function maybeOfferLaunchctlSetenv({
915
1135
  false,
916
1136
  );
917
1137
  if (!accept) return { set: false };
1138
+ return setLaunchctlMekoApiKey({ apiKey, runCommand, log });
1139
+ }
1140
+
1141
+ /**
1142
+ * Run `launchctl setenv MEKO_API_KEY <key>` so the macOS Codex.app launched
1143
+ * from Finder/Dock can authenticate during the current login session.
1144
+ *
1145
+ * The key is passed to launchctl but never logged.
1146
+ *
1147
+ * @returns {{set: boolean}}
1148
+ */
1149
+ export function setLaunchctlMekoApiKey({ apiKey, runCommand, log }) {
918
1150
  try {
919
1151
  runCommand(`launchctl setenv MEKO_API_KEY ${shellQuote(apiKey)}`);
920
1152
  log?.dim?.(" Set MEKO_API_KEY via launchctl (current login session).");
921
1153
  return { set: true };
922
1154
  } catch (err) {
923
- log?.warn?.(` launchctl setenv failed: ${err.message}`);
1155
+ log?.warn?.(" launchctl setenv failed; run it manually if Codex.app needs cloud auth.");
924
1156
  return { set: false };
925
1157
  }
926
1158
  }
@@ -5,6 +5,7 @@ import {
5
5
  symlinkSync,
6
6
  cpSync,
7
7
  lstatSync,
8
+ statSync,
8
9
  unlinkSync,
9
10
  rmSync,
10
11
  readFileSync,
@@ -108,14 +109,94 @@ export function shellQuote(value) {
108
109
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
109
110
  }
110
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
+
111
181
  /**
112
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
+ *
113
191
  * @param {string} source
114
192
  * @param {string} destination
115
193
  * @returns {"symlink"|"copy"}
116
194
  */
117
195
  export function symlinkOrCopy(source, destination) {
118
196
  mkdirSync(dirname(destination), { recursive: true });
197
+ if (isDanglingSymlink(destination)) {
198
+ unlinkAllowingMissing(destination);
199
+ }
119
200
  try {
120
201
  symlinkSync(source, destination, "junction");
121
202
  return "symlink";