fix(mem0-agent): bundle a launcher so hooks never depend on a global CLI
SessionStart failed with 'pyenv: mem0-agent: command not found' in any repo whose
.python-version selects a Python the console script was not installed into. A
pip entry point is the wrong dependency for a plugin.
- bin/mem0-agent: POSIX launcher that picks the first CPython >= 3.10 it can find
and runs the package from source (there are no third-party dependencies)
- hooks now invoke ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent, matching how the v1
plugin calls its own scripts; the generator enforces this
- app_id override renamed to MEM0_AGENT_APP_ID: v1 exports MEM0_PROJECT_ID, which
was silently overriding this plugin's project scope while both are installed
Verified: all six hooks exit 0 from a pyenv-3.11 repo, and app_id resolves per
repository again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/sh
|
||||
# Self-contained launcher. Hooks invoke this via ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent so
|
||||
# the plugin never depends on a globally-installed console script.
|
||||
#
|
||||
# A pip-installed entry point is not dependable here: under pyenv the shim resolves against
|
||||
# whichever Python version the current directory selects, so a repo with a .python-version
|
||||
# pinning a different version fails with "pyenv: mem0-agent: command not found".
|
||||
#
|
||||
# The package has no third-party dependencies, so any CPython >= 3.10 can run it straight
|
||||
# from source.
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGIN_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
SRC="$PLUGIN_ROOT/src"
|
||||
|
||||
if [ ! -d "$SRC/mem0_agent" ]; then
|
||||
echo "mem0-agent: package not found at $SRC" >&2
|
||||
exit 0 # never break a hook
|
||||
fi
|
||||
|
||||
# Does this interpreter exist and satisfy the minimum version?
|
||||
usable() {
|
||||
[ -n "${1:-}" ] || return 1
|
||||
command -v "$1" >/dev/null 2>&1 || return 1
|
||||
"$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
PY=""
|
||||
for candidate in \
|
||||
"${MEM0_AGENT_PYTHON:-}" \
|
||||
python3 \
|
||||
/opt/homebrew/bin/python3 \
|
||||
/usr/local/bin/python3 \
|
||||
/usr/bin/python3
|
||||
do
|
||||
if usable "$candidate"; then
|
||||
PY="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PY" ]; then
|
||||
# Last resort: any pyenv version that is new enough.
|
||||
for candidate in "$HOME"/.pyenv/versions/3.1[0-9]*/bin/python3; do
|
||||
if usable "$candidate"; then
|
||||
PY="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$PY" ]; then
|
||||
echo "mem0-agent: no Python >= 3.10 found; set MEM0_AGENT_PYTHON" >&2
|
||||
exit 0 # never break a hook
|
||||
fi
|
||||
|
||||
PYTHONPATH="$SRC${PYTHONPATH:+:$PYTHONPATH}"
|
||||
export PYTHONPATH
|
||||
exec "$PY" -m mem0_agent.cli "$@"
|
||||
@@ -215,8 +215,16 @@ def validate(spec: dict) -> dict:
|
||||
raise SpecError(
|
||||
f"hook {entry['id']!r} runs on {entry['event']} and must declare local_only: true"
|
||||
)
|
||||
if not str(entry["command"]).startswith("mem0-agent "):
|
||||
raise SpecError(f"hook {entry['id']!r}: command must invoke the mem0-agent CLI")
|
||||
# Commands must invoke the plugin's own bundled launcher. A bare console script is
|
||||
# not dependable: under pyenv the shim resolves against whichever Python version the
|
||||
# current directory selects, so a repo pinning a different version fails with
|
||||
# "pyenv: mem0-agent: command not found".
|
||||
cmd = str(entry["command"])
|
||||
if not cmd.startswith("${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent "):
|
||||
raise SpecError(
|
||||
f"hook {entry['id']!r}: command must invoke "
|
||||
"${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent"
|
||||
)
|
||||
return spec
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"",
|
||||
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Loading mem0 context..."
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 mem0-agent observe --source prompt",
|
||||
"command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent observe --source prompt",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
@@ -30,7 +30,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent assist-error >/dev/null 2>&1 &)",
|
||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
@@ -41,7 +41,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent flush >/dev/null 2>&1 &)",
|
||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
@@ -52,7 +52,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason precompact >/dev/null 2>&1 &)",
|
||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
@@ -63,7 +63,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason end >/dev/null 2>&1 &)",
|
||||
"command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# mem0-agent hook wiring -- the single source of truth.
|
||||
# "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent" hook wiring -- the single source of truth.
|
||||
#
|
||||
# v1 hand-maintained four editor dialects (Claude Code, Cursor, Codex, Antigravity).
|
||||
# They drifted: an event existed in one file and not another, timeouts disagreed, and
|
||||
@@ -71,7 +71,7 @@ hooks:
|
||||
- id: session-context
|
||||
event: "SessionStart"
|
||||
matcher: "startup|resume|compact"
|
||||
command: "mem0-agent context --session-id {session_id}"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent context --session-id {session_id}"
|
||||
timeout: 10
|
||||
background: false
|
||||
blocking: true
|
||||
@@ -82,7 +82,7 @@ hooks:
|
||||
- id: prompt-observe
|
||||
event: "UserPromptSubmit"
|
||||
matcher: null
|
||||
command: "mem0-agent observe --source prompt"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent observe --source prompt"
|
||||
timeout: 3
|
||||
background: false
|
||||
blocking: true
|
||||
@@ -93,7 +93,7 @@ hooks:
|
||||
- id: bash-assist-error
|
||||
event: "PostToolUse"
|
||||
matcher: "Bash"
|
||||
command: "mem0-agent assist-error"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
@@ -104,7 +104,7 @@ hooks:
|
||||
- id: stop-flush
|
||||
event: "Stop"
|
||||
matcher: null
|
||||
command: "mem0-agent flush"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
@@ -115,7 +115,7 @@ hooks:
|
||||
- id: precompact-flush
|
||||
event: "PreCompact"
|
||||
matcher: null
|
||||
command: "mem0-agent flush --reason precompact"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
@@ -126,7 +126,7 @@ hooks:
|
||||
- id: sessionend-flush
|
||||
event: "SessionEnd"
|
||||
matcher: null
|
||||
command: "mem0-agent flush --reason end"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
|
||||
@@ -170,7 +170,9 @@ def resolve_app_id(cwd: str | None = None, settings: Settings | None = None) ->
|
||||
env override -> cwd map -> remote-hash map (self-healing after a move)
|
||||
-> owner-repo slug -> directory name.
|
||||
"""
|
||||
override = os.environ.get("MEM0_PROJECT_ID")
|
||||
# Deliberately NOT MEM0_PROJECT_ID: the v1 plugin exports that, and while both are
|
||||
# installed it would silently override this plugin's project scope.
|
||||
override = os.environ.get("MEM0_AGENT_APP_ID")
|
||||
if override:
|
||||
return override.strip()
|
||||
cwd = cwd or os.getcwd()
|
||||
|
||||
@@ -134,8 +134,8 @@ def test_hook_manifest_commands_all_exist():
|
||||
for entry in entries:
|
||||
for hook in entry.get("hooks", []):
|
||||
cmd = hook["command"]
|
||||
assert "mem0-agent " in cmd
|
||||
sub = cmd.split("mem0-agent ", 1)[1].split()[0]
|
||||
assert "bin/mem0-agent " in cmd, "hooks must call the bundled launcher"
|
||||
sub = cmd.split("bin/mem0-agent ", 1)[1].split()[0]
|
||||
assert sub in known, f"manifest invokes unknown subcommand {sub!r}"
|
||||
found += 1
|
||||
assert found >= 6
|
||||
@@ -174,7 +174,7 @@ def test_every_manifest_command_parses_verbatim(monkeypatch):
|
||||
for hook in entry.get("hooks", []):
|
||||
raw = hook["command"].strip("() ").split(">/dev/null")[0]
|
||||
tokens = shlex.split(raw)
|
||||
idx = tokens.index("mem0-agent")
|
||||
idx = next(i for i, t in enumerate(tokens) if t.endswith("bin/mem0-agent"))
|
||||
argv = [t for t in tokens[idx + 1:] if t != "&"]
|
||||
# shell vars like "$CLAUDE_SESSION_ID" become a literal in the test
|
||||
argv = ["session-x" if t.startswith("$") else t for t in argv]
|
||||
|
||||
@@ -48,7 +48,10 @@ def test_every_hook_declares_its_contract(spec):
|
||||
assert entry["why"], f"{entry['id']} must say why it exists"
|
||||
assert isinstance(entry["local_only"], bool)
|
||||
assert isinstance(entry["background"], bool)
|
||||
assert entry["command"].startswith("mem0-agent ")
|
||||
assert entry["command"].startswith("${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent "), (
|
||||
"hooks must call the plugin's bundled launcher, not a global console script "
|
||||
"(a pyenv shim resolves against the directory's Python version and can vanish)"
|
||||
)
|
||||
|
||||
|
||||
def test_tiny_parser_handles_quotes_comments_and_nesting():
|
||||
|
||||
Reference in New Issue
Block a user