@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.
- package/README.md +68 -19
- 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 +291 -40
- package/assets/scripts/package-desktop-skill.sh +36 -0
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +1 -1
- 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 +4 -4
- package/lib/clients/claude-desktop.mjs +11 -8
- package/lib/clients/codex.mjs +236 -4
- package/lib/clients/common.mjs +81 -0
- package/lib/clients/cursor.mjs +47 -10
- package/lib/config.mjs +26 -0
- package/lib/installer.mjs +2 -2
- package/package.json +1 -1
- 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: '
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
43
|
-
* .skill bundle and prints import instructions.
|
|
44
|
-
* skill install API — the user drags the .skill
|
|
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
|
|
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
|
|
130
|
-
const script = join(
|
|
131
|
-
const output = join(
|
|
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
|
package/lib/clients/codex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?.(
|
|
1155
|
+
log?.warn?.(" launchctl setenv failed; run it manually if Codex.app needs cloud auth.");
|
|
924
1156
|
return { set: false };
|
|
925
1157
|
}
|
|
926
1158
|
}
|
package/lib/clients/common.mjs
CHANGED
|
@@ -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";
|