memleaf 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,460 @@
1
+ """Codex CLI detection and conservative MCP configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Mapping, Sequence
9
+
10
+ try:
11
+ import pwd
12
+ except ImportError: # pragma: no cover - Windows has no POSIX passwd DB.
13
+ pwd = None
14
+
15
+ from .base import (
16
+ CommandRunner,
17
+ ConfigureResult,
18
+ Detection,
19
+ HookMergeResult,
20
+ adapter_environment,
21
+ adapter_home,
22
+ absolute_vault,
23
+ command_is_missing,
24
+ host_event_command,
25
+ hook_activation_status as persisted_hook_activation_status,
26
+ hook_definition_fingerprint,
27
+ make_backup,
28
+ merge_hook_config,
29
+ mcp_command,
30
+ resolve_executable,
31
+ result_from_detection,
32
+ run_argv,
33
+ )
34
+
35
+
36
+ CODEX_EXECUTABLE = Path("/Applications/ChatGPT.app/Contents/Resources/codex")
37
+ _CODEX_HOOK_TRUST_STATUS = "pending_user_review"
38
+ _CODEX_HOOK_USER_ACTION = "Open Codex and run /hooks to review and trust the memleaf hooks."
39
+
40
+
41
+ class CodexAdapter:
42
+ """Use the official Codex CLI for user-level MCP configuration.
43
+
44
+ The adapter never edits TOML itself. It asks ``codex mcp get`` what is
45
+ already configured, and only then invokes the documented ``mcp add``
46
+ command for a missing entry.
47
+ """
48
+
49
+ agent = "codex"
50
+
51
+ def __init__(
52
+ self,
53
+ home: Path | str | None = None,
54
+ env: Mapping[str, str] | None = None,
55
+ runner: CommandRunner | None = None,
56
+ *,
57
+ command_runner: CommandRunner | None = None,
58
+ path: str | Sequence[str] | None = None,
59
+ known_paths: Sequence[Path | str] | None = None,
60
+ ) -> None:
61
+ if runner is not None and command_runner is not None:
62
+ raise ValueError("provide runner or command_runner, not both")
63
+ home_was_explicit = home is not None or (env is not None and "HOME" in env)
64
+ self.env = adapter_environment(env)
65
+ effective_home = home if home is not None else self.env.get("HOME")
66
+ self.home = adapter_home(effective_home)
67
+ if known_paths is None:
68
+ # An explicit home marks an injected/sandbox boundary. Do not let
69
+ # a system installation escape that boundary when init is tested
70
+ # with an isolated HOME. The default adapter may still inspect
71
+ # the documented application path.
72
+ try:
73
+ if pwd is None:
74
+ raise OSError
75
+ login_home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve()
76
+ except (KeyError, OSError, RuntimeError):
77
+ login_home = None
78
+ self.known_paths = (
79
+ (CODEX_EXECUTABLE,)
80
+ if not home_was_explicit or self.home == login_home
81
+ else ()
82
+ )
83
+ else:
84
+ self.known_paths = tuple(known_paths)
85
+ if path is not None:
86
+ self.env["PATH"] = (
87
+ path if isinstance(path, str) else os.pathsep.join(path)
88
+ )
89
+ self.runner = runner or command_runner
90
+
91
+ @property
92
+ def config_path(self) -> Path:
93
+ return self.home / ".codex" / "config.toml"
94
+
95
+ @property
96
+ def hooks_path(self) -> Path:
97
+ return self.home / ".codex" / "hooks.json"
98
+
99
+ def detect(self) -> Detection:
100
+ config = self.config_path
101
+ executable = resolve_executable(
102
+ "codex", env=self.env, known_paths=self.known_paths
103
+ )
104
+ config_value = str(config)
105
+ if executable is not None:
106
+ return Detection(
107
+ agent=self.agent,
108
+ detected=True,
109
+ confidence="high",
110
+ reason="executable found",
111
+ executable=executable,
112
+ config_path=config_value,
113
+ status="detected",
114
+ )
115
+ if config.is_file() and not config.is_symlink():
116
+ return Detection(
117
+ agent=self.agent,
118
+ detected=True,
119
+ confidence="medium",
120
+ reason="user configuration found but executable is unavailable",
121
+ executable=None,
122
+ config_path=config_value,
123
+ status="diagnostic",
124
+ )
125
+ if config.is_symlink():
126
+ return Detection(
127
+ agent=self.agent,
128
+ detected=False,
129
+ confidence="low",
130
+ reason="configuration path is a symlink; refusing automatic access",
131
+ executable=None,
132
+ config_path=config_value,
133
+ status="diagnostic",
134
+ )
135
+ return Detection(
136
+ agent=self.agent,
137
+ detected=False,
138
+ confidence="none",
139
+ reason="executable and user configuration were not found",
140
+ executable=None,
141
+ config_path=config_value,
142
+ status="not_detected",
143
+ )
144
+
145
+ def configure(
146
+ self,
147
+ detection: Detection | Path | str | None = None,
148
+ vault: Path | str | None = None,
149
+ *,
150
+ dry_run: bool = False,
151
+ attempt: bool = False,
152
+ ) -> ConfigureResult:
153
+ if detection is not None and not isinstance(detection, Detection) and vault is None:
154
+ vault = detection
155
+ detection = None
156
+ detection = self._coerce_detection(detection, vault)
157
+ if vault is None:
158
+ raise ValueError("vault is required")
159
+ if not detection.detected or detection.confidence != "high":
160
+ return result_from_detection(
161
+ detection,
162
+ status="diagnostic" if attempt else "not_detected",
163
+ reason=(
164
+ "host was not reliably detected; no executable path guessed"
165
+ if attempt
166
+ else detection.reason
167
+ ),
168
+ dry_run=dry_run,
169
+ )
170
+ if not detection.executable:
171
+ return result_from_detection(
172
+ detection,
173
+ status="diagnostic",
174
+ reason="detected host has no executable path",
175
+ dry_run=dry_run,
176
+ )
177
+
178
+ executable = detection.executable
179
+ get_command = [executable, "mcp", "get", "memleaf", "--json"]
180
+ add_command = [
181
+ executable,
182
+ "mcp",
183
+ "add",
184
+ "memleaf",
185
+ "--",
186
+ *mcp_command(vault),
187
+ ]
188
+ hook_definition = _codex_hook_definition(vault)
189
+ hook_hash = hook_definition_fingerprint(hook_definition)
190
+ activation_status = persisted_hook_activation_status(
191
+ vault,
192
+ self.agent,
193
+ hook_hash,
194
+ _CODEX_HOOK_TRUST_STATUS,
195
+ )
196
+ hook_trust_status = "trusted" if activation_status == "active" else _CODEX_HOOK_TRUST_STATUS
197
+ if dry_run:
198
+ hook_result = _configure_codex_hooks(self.hooks_path, vault, dry_run=True)
199
+ if hook_result.status == "diagnostic":
200
+ return result_from_detection(
201
+ detection,
202
+ status="diagnostic",
203
+ reason=hook_result.reason,
204
+ command=add_command,
205
+ dry_run=True,
206
+ hook_trust_status=_CODEX_HOOK_TRUST_STATUS,
207
+ hook_activation_status=_CODEX_HOOK_TRUST_STATUS,
208
+ hook_definition_hash=hook_hash,
209
+ user_action_required=True,
210
+ user_action=_CODEX_HOOK_USER_ACTION,
211
+ )
212
+ return result_from_detection(
213
+ detection,
214
+ status="would_configure",
215
+ reason="would configure MCP entry and lifecycle hooks",
216
+ command=add_command,
217
+ dry_run=True,
218
+ hook_trust_status=hook_trust_status,
219
+ hook_activation_status=activation_status,
220
+ hook_definition_hash=hook_hash,
221
+ user_action_required=activation_status != "active",
222
+ user_action=_CODEX_HOOK_USER_ACTION if activation_status != "active" else None,
223
+ )
224
+
225
+ hook_preflight = _configure_codex_hooks(self.hooks_path, vault, dry_run=True)
226
+ if hook_preflight.status == "diagnostic":
227
+ return result_from_detection(
228
+ detection,
229
+ status="diagnostic",
230
+ reason=hook_preflight.reason,
231
+ command=get_command,
232
+ hook_trust_status=_CODEX_HOOK_TRUST_STATUS,
233
+ hook_activation_status=_CODEX_HOOK_TRUST_STATUS,
234
+ hook_definition_hash=hook_hash,
235
+ user_action_required=True,
236
+ user_action=_CODEX_HOOK_USER_ACTION,
237
+ )
238
+
239
+ config = Path(detection.config_path) if detection.config_path else self.config_path
240
+ if config.is_symlink():
241
+ return result_from_detection(
242
+ detection,
243
+ status="diagnostic",
244
+ reason="configuration path is a symlink; unchanged",
245
+ command=get_command,
246
+ )
247
+ if config.exists() and not config.is_file():
248
+ return result_from_detection(
249
+ detection,
250
+ status="diagnostic",
251
+ reason="configuration path is not a regular file; unchanged",
252
+ command=get_command,
253
+ )
254
+
255
+ try:
256
+ queried = run_argv(self.runner, get_command, env=self.env)
257
+ except Exception:
258
+ return result_from_detection(
259
+ detection,
260
+ status="failure",
261
+ reason="could not query existing MCP entry",
262
+ command=get_command,
263
+ )
264
+ if queried.returncode == 0:
265
+ entry = _entry_from_json(queried.stdout)
266
+ if entry is not None and _entry_matches(entry, vault):
267
+ mcp_changed = False
268
+ backup = None
269
+ mcp_reason = "existing memleaf entry is correct"
270
+ else:
271
+ return result_from_detection(
272
+ detection,
273
+ status="diagnostic",
274
+ reason="existing memleaf entry is unknown or conflicting; unchanged",
275
+ command=get_command,
276
+ )
277
+ elif command_is_missing(queried):
278
+ backup = None
279
+ try:
280
+ backup = make_backup(config)
281
+ except Exception:
282
+ return result_from_detection(
283
+ detection,
284
+ status="failure",
285
+ reason="could not create configuration backup; unchanged",
286
+ command=add_command,
287
+ )
288
+ try:
289
+ added = run_argv(self.runner, add_command, env=self.env)
290
+ except Exception:
291
+ return result_from_detection(
292
+ detection,
293
+ status="failure",
294
+ reason="official CLI add failed; backup retained",
295
+ backup_path=backup,
296
+ command=add_command,
297
+ )
298
+ if added.returncode != 0:
299
+ return result_from_detection(
300
+ detection,
301
+ status="failure",
302
+ reason="official CLI add failed; backup retained",
303
+ backup_path=backup,
304
+ command=add_command,
305
+ )
306
+ mcp_changed = True
307
+ mcp_reason = "MCP entry added by official CLI"
308
+ else:
309
+ return result_from_detection(
310
+ detection,
311
+ status="failure",
312
+ reason="could not establish whether memleaf is already configured",
313
+ command=get_command,
314
+ )
315
+
316
+ hook_result = _configure_codex_hooks(self.hooks_path, vault)
317
+ if hook_result.status in ("diagnostic", "failure"):
318
+ return result_from_detection(
319
+ detection,
320
+ status=hook_result.status,
321
+ reason=hook_result.reason,
322
+ changed=mcp_changed,
323
+ backup_path=hook_result.backup_path or backup,
324
+ command=add_command,
325
+ hook_trust_status=_CODEX_HOOK_TRUST_STATUS,
326
+ hook_activation_status=_CODEX_HOOK_TRUST_STATUS,
327
+ hook_definition_hash=hook_hash,
328
+ user_action_required=True,
329
+ user_action=_CODEX_HOOK_USER_ACTION,
330
+ )
331
+ return result_from_detection(
332
+ detection,
333
+ # Preserve the adapter's historical status for an existing MCP
334
+ # entry; ``changed`` still reports that lifecycle hooks were added.
335
+ status="configured" if mcp_changed else "already_configured",
336
+ reason=(
337
+ f"{mcp_reason}; lifecycle hooks configured"
338
+ if mcp_changed and hook_result.changed
339
+ else "lifecycle hooks configured"
340
+ if hook_result.changed
341
+ else mcp_reason
342
+ ),
343
+ changed=mcp_changed or hook_result.changed,
344
+ backup_path=backup or hook_result.backup_path,
345
+ command=add_command,
346
+ hook_trust_status=hook_trust_status,
347
+ hook_activation_status=activation_status,
348
+ hook_definition_hash=hook_hash,
349
+ user_action_required=activation_status != "active",
350
+ user_action=_CODEX_HOOK_USER_ACTION if activation_status != "active" else None,
351
+ )
352
+
353
+ def _coerce_detection(
354
+ self,
355
+ detection: Detection | Path | str | None,
356
+ vault: Path | str | None,
357
+ ) -> Detection:
358
+ if isinstance(detection, Detection) or detection is None:
359
+ return detection or self.detect()
360
+ if vault is None:
361
+ return self.detect()
362
+ return self.detect()
363
+
364
+
365
+ Codex = CodexAdapter
366
+
367
+
368
+ def _configure_codex_hooks(
369
+ path: Path,
370
+ vault: Path | str,
371
+ *,
372
+ dry_run: bool = False,
373
+ interpreter: str | Path | None = None,
374
+ ) -> HookMergeResult:
375
+ definition = _codex_hook_definition(vault, interpreter=interpreter)
376
+ return merge_hook_config(
377
+ path,
378
+ definition,
379
+ container_key="hooks",
380
+ dry_run=dry_run,
381
+ )
382
+
383
+
384
+ def _codex_hook_definition(
385
+ vault: Path | str,
386
+ *,
387
+ interpreter: str | Path | None = None,
388
+ ) -> dict[str, list[dict[str, Any]]]:
389
+ command = host_event_command("codex", "UserPromptSubmit", vault, interpreter=interpreter)
390
+ pre_tool_command = host_event_command("codex", "PreToolUse", vault, interpreter=interpreter)
391
+ post_tool_command = host_event_command("codex", "PostToolUse", vault, interpreter=interpreter)
392
+ stop_command = host_event_command("codex", "Stop", vault, interpreter=interpreter)
393
+ return {
394
+ "UserPromptSubmit": [{"hooks": [{"type": "command", "command": command, "timeout": 600}]}],
395
+ "PreToolUse": [
396
+ {
397
+ "matcher": r"^mcp__memleaf__(search|read)$",
398
+ "hooks": [{"type": "command", "command": pre_tool_command, "timeout": 30}],
399
+ }
400
+ ],
401
+ "PostToolUse": [
402
+ {
403
+ "matcher": r"^mcp__memleaf__search$",
404
+ "hooks": [{"type": "command", "command": post_tool_command, "timeout": 30}],
405
+ }
406
+ ],
407
+ "Stop": [{"hooks": [{"type": "command", "command": stop_command, "timeout": 600}]}],
408
+ }
409
+
410
+
411
+ def _entry_from_json(text: str) -> dict[str, Any] | None:
412
+ try:
413
+ value = json.loads(text)
414
+ except (TypeError, ValueError):
415
+ return None
416
+ return _find_named_entry(value, "memleaf")
417
+
418
+
419
+ def _find_named_entry(value: Any, name: str) -> dict[str, Any] | None:
420
+ if isinstance(value, Mapping):
421
+ if "command" in value and "args" in value:
422
+ return dict(value)
423
+ direct = value.get(name)
424
+ if isinstance(direct, Mapping):
425
+ return dict(direct)
426
+ if value.get("name") == name:
427
+ return dict(value)
428
+ for key in ("mcpServers", "mcp_servers", "servers", "server", "mcp"):
429
+ nested = value.get(key)
430
+ if isinstance(nested, Mapping):
431
+ found = _find_named_entry(nested, name)
432
+ if found is not None:
433
+ return found
434
+ for nested in value.values():
435
+ if isinstance(nested, Mapping):
436
+ if nested.get("name") == name:
437
+ return dict(nested)
438
+ if "command" in nested and "args" in nested:
439
+ return dict(nested)
440
+ elif isinstance(value, list):
441
+ for nested in value:
442
+ found = _find_named_entry(nested, name)
443
+ if found is not None:
444
+ return found
445
+ return None
446
+
447
+
448
+ def _entry_matches(entry: Mapping[str, Any], vault: Path | str) -> bool:
449
+ candidate: Mapping[str, Any] = entry
450
+ transport = entry.get("transport")
451
+ if transport is not None:
452
+ if not isinstance(transport, Mapping) or transport.get("type") != "stdio":
453
+ return False
454
+ candidate = transport
455
+ elif candidate.get("type") not in (None, "stdio"):
456
+ return False
457
+ return candidate.get("command") == "memleaf-mcp" and candidate.get("args") == [
458
+ "--vault",
459
+ str(Path(vault).expanduser().resolve()),
460
+ ]