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,746 @@
1
+ """Shared, dependency-free primitives for host adapters.
2
+
3
+ The adapters deliberately expose only paths, statuses, and commands in their
4
+ results. Command output is kept private to the adapter so configuration
5
+ contents and possible secrets cannot accidentally end up in ``agents.json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as _datetime
11
+ import hashlib
12
+ import inspect
13
+ import json
14
+ import os
15
+ import shlex
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Mapping, Sequence
24
+
25
+ from ..locking import VaultLock, atomic_write_json, read_json
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CommandResult:
30
+ """Small normalized representation of one argv invocation."""
31
+
32
+ returncode: int
33
+ stdout: str = ""
34
+ stderr: str = ""
35
+
36
+
37
+ @dataclass
38
+ class Detection:
39
+ """Safe, serializable evidence about one supported host."""
40
+
41
+ agent: str
42
+ detected: bool
43
+ confidence: str = "unknown"
44
+ reason: str = ""
45
+ executable: str | None = None
46
+ config_path: str | None = None
47
+ status: str = "unknown"
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "agent": self.agent,
52
+ "detected": bool(self.detected),
53
+ "confidence": self.confidence,
54
+ "reason": self.reason,
55
+ "executable": self.executable,
56
+ "config_path": self.config_path,
57
+ "status": self.status,
58
+ }
59
+
60
+ as_dict = to_dict
61
+
62
+
63
+ @dataclass
64
+ class ConfigureResult:
65
+ """Safe, serializable outcome of one adapter configuration attempt."""
66
+
67
+ agent: str
68
+ detected: bool = False
69
+ confidence: str = "unknown"
70
+ reason: str = ""
71
+ executable: str | None = None
72
+ config_path: str | None = None
73
+ status: str = "skipped"
74
+ changed: bool = False
75
+ backup_path: str | None = None
76
+ command: list[str] | None = None
77
+ dry_run: bool = False
78
+ hook_trust_status: str | None = None
79
+ hook_activation_status: str | None = None
80
+ hook_definition_hash: str | None = None
81
+ user_action_required: bool | None = None
82
+ user_action: str | None = None
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ result = {
86
+ "agent": self.agent,
87
+ "detected": bool(self.detected),
88
+ "confidence": self.confidence,
89
+ "reason": self.reason,
90
+ "executable": self.executable,
91
+ "config_path": self.config_path,
92
+ "status": self.status,
93
+ "changed": bool(self.changed),
94
+ "backup_path": self.backup_path,
95
+ "command": list(self.command) if self.command is not None else None,
96
+ "dry_run": bool(self.dry_run),
97
+ }
98
+ if self.hook_trust_status is not None:
99
+ result["hook_trust_status"] = self.hook_trust_status
100
+ if self.hook_activation_status is not None:
101
+ result["hook_activation_status"] = self.hook_activation_status
102
+ if self.hook_definition_hash is not None:
103
+ result["hook_definition_hash"] = self.hook_definition_hash
104
+ if self.user_action_required is not None:
105
+ result["user_action_required"] = bool(self.user_action_required)
106
+ if self.user_action is not None:
107
+ result["user_action"] = self.user_action
108
+ return result
109
+
110
+ as_dict = to_dict
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class HookMergeResult:
115
+ """Safe result from merging one host hook configuration."""
116
+
117
+ status: str
118
+ reason: str
119
+ changed: bool = False
120
+ backup_path: Path | None = None
121
+
122
+
123
+ CommandRunner = Callable[..., Any]
124
+
125
+
126
+ def adapter_home(home: Path | str | None = None) -> Path:
127
+ """Return an absolute home path without changing the process environment."""
128
+
129
+ return (Path(home).expanduser() if home is not None else Path.home()).resolve()
130
+
131
+
132
+ def adapter_environment(env: Mapping[str, str] | None = None) -> dict[str, str]:
133
+ """Copy an injectable environment for executable discovery and runners."""
134
+
135
+ return dict(os.environ if env is None else env)
136
+
137
+
138
+ def resolve_executable(
139
+ name: str,
140
+ *,
141
+ env: Mapping[str, str],
142
+ known_paths: Sequence[Path | str] = (),
143
+ ) -> str | None:
144
+ """Resolve an executable from the injected PATH, then known locations."""
145
+
146
+ path_value = env.get("PATH")
147
+ from_path = shutil.which(name, path=path_value)
148
+ candidates: list[Path] = []
149
+ if from_path:
150
+ candidates.append(Path(from_path))
151
+ candidates.extend(Path(candidate).expanduser() for candidate in known_paths)
152
+ for candidate in candidates:
153
+ try:
154
+ if candidate.is_file() and os.access(candidate, os.X_OK):
155
+ return str(candidate.resolve())
156
+ except OSError:
157
+ continue
158
+ return None
159
+
160
+
161
+ def absolute_vault(vault: Path | str) -> str:
162
+ """Build the one supported MCP command argument without creating a vault."""
163
+
164
+ return str(Path(vault).expanduser().resolve())
165
+
166
+
167
+ def mcp_command(vault: Path | str) -> list[str]:
168
+ return ["memleaf-mcp", "--vault", absolute_vault(vault)]
169
+
170
+
171
+ def hook_definition_fingerprint(definition: Mapping[str, Any]) -> str:
172
+ """Return a stable, non-reversible identity for one host hook definition."""
173
+
174
+ payload = json.dumps(
175
+ definition,
176
+ ensure_ascii=False,
177
+ sort_keys=True,
178
+ separators=(",", ":"),
179
+ ).encode("utf-8")
180
+ return hashlib.sha256(payload).hexdigest()
181
+
182
+
183
+ def agent_index_path(vault: Path | str) -> Path:
184
+ """Return the agents index path without creating or changing the vault."""
185
+
186
+ root = vault if isinstance(vault, (str, os.PathLike)) else getattr(vault, "root", vault)
187
+ return Path(root).expanduser().resolve() / "_index" / "agents.json"
188
+
189
+
190
+ def _read_agents_index(path: Path) -> dict[str, Any] | None:
191
+ if path.is_symlink() or (path.exists() and not path.is_file()):
192
+ return None
193
+ if not path.exists():
194
+ return {"version": 1, "agents": {}}
195
+ try:
196
+ value = read_json(path)
197
+ except (OSError, UnicodeError, TypeError, ValueError):
198
+ return None
199
+ if not isinstance(value, dict):
200
+ return None
201
+ agents = value.get("agents")
202
+ if agents is None:
203
+ agents = {}
204
+ if not isinstance(agents, dict):
205
+ return None
206
+ result = dict(value)
207
+ result["agents"] = dict(agents)
208
+ return result
209
+
210
+
211
+ def hook_activation_status(
212
+ vault: Path | str,
213
+ agent: str,
214
+ definition_hash: str,
215
+ pending_status: str,
216
+ ) -> str:
217
+ """Keep ``active`` only when it belongs to the current hook definition."""
218
+
219
+ index = _read_agents_index(agent_index_path(vault))
220
+ if index is None:
221
+ return pending_status
222
+ entry = index["agents"].get(agent)
223
+ if not isinstance(entry, Mapping):
224
+ return pending_status
225
+ if (
226
+ entry.get("hook_activation_status") == "active"
227
+ and entry.get("hook_definition_hash") == definition_hash
228
+ ):
229
+ return "active"
230
+ return pending_status
231
+
232
+
233
+ def update_agents_index(
234
+ path: Path | str,
235
+ updates: Mapping[str, Mapping[str, Any]],
236
+ ) -> bool:
237
+ """Atomically merge agent entries while retaining other index data."""
238
+
239
+ target = Path(path)
240
+ if target.is_symlink() or target.parent.is_symlink():
241
+ return False
242
+ lock_path = target.parent / "vault.lock"
243
+ try:
244
+ with VaultLock(lock_path):
245
+ current = _read_agents_index(target)
246
+ if current is None:
247
+ return False
248
+ agents = dict(current["agents"])
249
+ changed = False
250
+ for agent, update in updates.items():
251
+ if not isinstance(agent, str) or not agent or not isinstance(update, Mapping):
252
+ return False
253
+ previous = agents.get(agent)
254
+ merged = dict(previous) if isinstance(previous, Mapping) else {}
255
+ merged.update(dict(update))
256
+ if merged.get("hook_activation_status") == "active":
257
+ merged["user_action_required"] = False
258
+ merged.pop("user_action", None)
259
+ if merged != previous:
260
+ changed = True
261
+ agents[agent] = merged
262
+ if not changed:
263
+ return True
264
+ current["agents"] = agents
265
+ mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
266
+ atomic_write_json(target, current, mode=mode or 0o600)
267
+ return True
268
+ except Exception:
269
+ return False
270
+
271
+
272
+ def mark_hook_active(vault: Path | str, agent: str) -> bool:
273
+ """Record a successful real hook invocation without touching hook trust."""
274
+
275
+ target = agent_index_path(vault)
276
+ if target.is_symlink() or target.parent.is_symlink():
277
+ return False
278
+ lock_path = target.parent / "vault.lock"
279
+ try:
280
+ with VaultLock(lock_path):
281
+ current = _read_agents_index(target)
282
+ if current is None:
283
+ return False
284
+ agents = dict(current["agents"])
285
+ existing = agents.get(agent)
286
+ if not isinstance(existing, Mapping):
287
+ return False
288
+ updated = dict(existing)
289
+ updated["hook_activation_status"] = "active"
290
+ updated["user_action_required"] = False
291
+ updated.pop("user_action", None)
292
+ if updated == existing:
293
+ return True
294
+ agents[agent] = updated
295
+ current["agents"] = agents
296
+ mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
297
+ atomic_write_json(target, current, mode=mode or 0o600)
298
+ return True
299
+ except Exception:
300
+ return False
301
+
302
+
303
+ def host_event_command(
304
+ host: str,
305
+ event: str,
306
+ vault: Path | str,
307
+ *,
308
+ interpreter: str | Path | None = None,
309
+ ) -> str:
310
+ """Return a shell-safe hook command using the installed interpreter.
311
+
312
+ GUI-launched hosts do not necessarily inherit the user's interactive PATH,
313
+ so the hook must not depend on the ``memleaf`` console script being found.
314
+ ``sys.executable`` is kept as-is (including a venv symlink) and every
315
+ argument is quoted for the host's command runner.
316
+ """
317
+
318
+ value = str(interpreter if interpreter is not None else sys.executable)
319
+ executable = Path(value).expanduser()
320
+ if not executable.is_absolute():
321
+ executable = Path.cwd() / executable
322
+ args = (
323
+ str(executable),
324
+ "-m",
325
+ "memleaf.cli",
326
+ "host-event",
327
+ host,
328
+ event,
329
+ "--vault",
330
+ absolute_vault(vault),
331
+ )
332
+ return " ".join(shlex.quote(argument) for argument in args)
333
+
334
+
335
+ def run_argv(
336
+ runner: CommandRunner | None,
337
+ argv: Sequence[str],
338
+ *,
339
+ env: Mapping[str, str],
340
+ input_text: str | None = None,
341
+ ) -> CommandResult:
342
+ """Call an injected runner or subprocess with an argv list only.
343
+
344
+ A few small runner signatures are accepted to keep adapters convenient to
345
+ test: ``runner(argv, env=env)``, ``runner(argv, env)``, and ``runner(argv)``.
346
+ Runners that explicitly accept ``input_text`` can inspect the optional
347
+ stdin payload as well. No fallback invokes a shell or retries a callable.
348
+ """
349
+
350
+ command = list(argv)
351
+
352
+ # Keep the injected-runner contract backwards compatible: only a runner
353
+ # that explicitly exposes input_text receives it. The real subprocess
354
+ # path is the only default that needs to create a pipe for stdin.
355
+ if runner is None:
356
+ return normalize_command_result(
357
+ _subprocess_runner(command, env=env, input_text=input_text)
358
+ )
359
+
360
+ actual_runner = runner
361
+ call_args: tuple[Any, ...] = (command,)
362
+ call_kwargs: dict[str, Any] = {"env": env}
363
+ try:
364
+ signature = inspect.signature(actual_runner)
365
+ except (TypeError, ValueError):
366
+ # If a callable does not expose a signature, use one convention only;
367
+ # a TypeError raised by its body must never cause a second invocation.
368
+ value = actual_runner(command, env=env)
369
+ else:
370
+ candidates = []
371
+ if input_text is not None:
372
+ candidates.append(((command,), {"env": env, "input_text": input_text}))
373
+ candidates.extend(
374
+ (
375
+ ((command,), {"env": env}),
376
+ ((command, env), {}),
377
+ ((command,), {}),
378
+ )
379
+ )
380
+ for candidate_args, candidate_kwargs in candidates:
381
+ try:
382
+ signature.bind(*candidate_args, **candidate_kwargs)
383
+ except TypeError:
384
+ continue
385
+ call_args = candidate_args
386
+ call_kwargs = candidate_kwargs
387
+ break
388
+ value = actual_runner(*call_args, **call_kwargs)
389
+ return normalize_command_result(value)
390
+
391
+
392
+ def _subprocess_runner(
393
+ argv: Sequence[str],
394
+ *,
395
+ env: Mapping[str, str],
396
+ input_text: str | None = None,
397
+ ) -> Any:
398
+ # Host commands are always argv lists; no shell is involved.
399
+ options: dict[str, Any] = {
400
+ "check": False,
401
+ "capture_output": True,
402
+ "text": True,
403
+ "env": dict(env),
404
+ }
405
+ if input_text is not None:
406
+ options["input"] = input_text
407
+ return subprocess.run(list(argv), **options)
408
+
409
+
410
+ def normalize_command_result(value: Any) -> CommandResult:
411
+ """Normalize common fake-runner and ``subprocess`` return shapes."""
412
+
413
+ if isinstance(value, CommandResult):
414
+ return value
415
+ if hasattr(value, "returncode"):
416
+ return CommandResult(
417
+ _as_returncode(getattr(value, "returncode", 1)),
418
+ _as_text(getattr(value, "stdout", "")),
419
+ _as_text(getattr(value, "stderr", "")),
420
+ )
421
+ if isinstance(value, Mapping):
422
+ return CommandResult(
423
+ _as_returncode(value.get("returncode", value.get("code", 1))),
424
+ _as_text(value.get("stdout", "")),
425
+ _as_text(value.get("stderr", "")),
426
+ )
427
+ if isinstance(value, (tuple, list)):
428
+ values = list(value)
429
+ return CommandResult(
430
+ _as_returncode(values[0] if values else 1),
431
+ _as_text(values[1] if len(values) > 1 else ""),
432
+ _as_text(values[2] if len(values) > 2 else ""),
433
+ )
434
+ if isinstance(value, int):
435
+ return CommandResult(value)
436
+ return CommandResult(1)
437
+
438
+
439
+ def _as_returncode(value: Any) -> int:
440
+ try:
441
+ return int(value)
442
+ except (TypeError, ValueError):
443
+ return 1
444
+
445
+
446
+ def _as_text(value: Any) -> str:
447
+ if value is None:
448
+ return ""
449
+ if isinstance(value, bytes):
450
+ return value.decode("utf-8", errors="replace")
451
+ return str(value)
452
+
453
+
454
+ def command_is_missing(result: CommandResult) -> bool:
455
+ """Recognize explicit not-found responses without exposing their text."""
456
+
457
+ if result.returncode == 0:
458
+ return False
459
+ text = f"{result.stdout} {result.stderr}".lower()
460
+ markers = (
461
+ "not found",
462
+ "not configured",
463
+ "does not exist",
464
+ "no such",
465
+ "unknown mcp",
466
+ "unknown server",
467
+ "server not found",
468
+ "no mcp server",
469
+ )
470
+ return any(marker in text for marker in markers)
471
+
472
+
473
+ def make_backup(path: Path | str) -> Path | None:
474
+ """Create a same-directory timestamped backup, refusing symlinks."""
475
+
476
+ source = Path(path)
477
+ if not source.exists():
478
+ return None
479
+ if source.is_symlink() or not source.is_file():
480
+ raise OSError("unsafe configuration path")
481
+ mode = source.stat().st_mode & 0o7777
482
+ timestamp = _datetime.datetime.now(_datetime.timezone.utc).strftime(
483
+ "%Y%m%dT%H%M%S%fZ"
484
+ )
485
+ for _ in range(8):
486
+ backup = source.with_name(
487
+ f"{source.name}.memleaf.bak.{timestamp}.{uuid.uuid4().hex[:10]}"
488
+ )
489
+ if backup.exists() or backup.is_symlink():
490
+ continue
491
+ shutil.copy2(source, backup)
492
+ try:
493
+ os.chmod(backup, mode)
494
+ except OSError:
495
+ pass
496
+ _fsync_file(backup)
497
+ _fsync_directory(backup.parent)
498
+ return backup
499
+ raise OSError("could not create configuration backup")
500
+
501
+
502
+ def atomic_replace_bytes(path: Path | str, data: bytes, *, mode: int = 0o600) -> None:
503
+ """Atomically replace one regular file with fsync and a same-dir temp."""
504
+
505
+ target = Path(path)
506
+ target.parent.mkdir(parents=True, exist_ok=True)
507
+ descriptor: int | None = None
508
+ temporary: Path | None = None
509
+ try:
510
+ descriptor, temporary_name = tempfile.mkstemp(
511
+ prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
512
+ )
513
+ temporary = Path(temporary_name)
514
+ os.fchmod(descriptor, mode)
515
+ with os.fdopen(descriptor, "wb") as stream:
516
+ descriptor = None
517
+ stream.write(data)
518
+ stream.flush()
519
+ os.fsync(stream.fileno())
520
+ os.replace(temporary, target)
521
+ temporary = None
522
+ _fsync_directory(target.parent)
523
+ finally:
524
+ if descriptor is not None:
525
+ os.close(descriptor)
526
+ if temporary is not None:
527
+ try:
528
+ temporary.unlink()
529
+ except FileNotFoundError:
530
+ pass
531
+
532
+
533
+ def merge_hook_config(
534
+ path: Path | str,
535
+ additions: Mapping[str, Sequence[Mapping[str, Any]]],
536
+ *,
537
+ container_key: str | None = None,
538
+ dry_run: bool = False,
539
+ ) -> HookMergeResult:
540
+ """Merge command handlers into a host hook JSON file.
541
+
542
+ ``container_key`` is used by Antigravity's named-hook format. Codex uses
543
+ a top-level ``hooks`` object and passes ``container_key="hooks"``.
544
+ Existing handlers are left untouched; a matching memleaf command is
545
+ idempotent and a different memleaf command is treated as a conflict.
546
+ """
547
+
548
+ target = Path(path)
549
+ if target.is_symlink():
550
+ return HookMergeResult("diagnostic", "hook configuration is a symlink; unchanged")
551
+ # Check the host-specific directory levels without rejecting normal
552
+ # system path aliases such as macOS's /var -> /private/var.
553
+ if any(parent.is_symlink() for parent in (target.parent, target.parent.parent)):
554
+ return HookMergeResult("diagnostic", "hook configuration parent is a symlink; unchanged")
555
+ if target.exists() and not target.is_file():
556
+ return HookMergeResult("diagnostic", "hook configuration is not a regular file; unchanged")
557
+
558
+ if target.exists():
559
+ try:
560
+ value = json.loads(target.read_text(encoding="utf-8"))
561
+ except (OSError, UnicodeError, ValueError):
562
+ return HookMergeResult("diagnostic", "hook configuration is invalid JSON; unchanged")
563
+ if not isinstance(value, dict):
564
+ return HookMergeResult("diagnostic", "hook configuration is not a JSON object; unchanged")
565
+ document: dict[str, Any] = dict(value)
566
+ else:
567
+ document = {}
568
+
569
+ if container_key is None:
570
+ container: dict[str, Any] = document
571
+ else:
572
+ current = document.get(container_key)
573
+ if current is None:
574
+ current = {}
575
+ if not isinstance(current, Mapping):
576
+ return HookMergeResult("diagnostic", "hook event container is not a JSON object; unchanged")
577
+ container = dict(current)
578
+ if container_key != "hooks" and container.get("enabled") is False:
579
+ return HookMergeResult("diagnostic", "existing memleaf hook is disabled; unchanged")
580
+
581
+ updated = False
582
+ for event, handlers in additions.items():
583
+ if not isinstance(event, str) or not event or not isinstance(handlers, Sequence):
584
+ return HookMergeResult("diagnostic", "invalid hook definition; unchanged")
585
+ existing = container.get(event)
586
+ if existing is None:
587
+ existing_items: list[Any] = []
588
+ elif isinstance(existing, list):
589
+ existing_items = list(existing)
590
+ else:
591
+ return HookMergeResult("diagnostic", "hook event is not an array; unchanged")
592
+
593
+ for handler in handlers:
594
+ if not isinstance(handler, Mapping):
595
+ return HookMergeResult("diagnostic", "invalid hook handler; unchanged")
596
+ requested = handler.get("hooks") if container_key == "hooks" else [handler]
597
+ if not isinstance(requested, list) or not requested:
598
+ return HookMergeResult("diagnostic", "invalid hook handler; unchanged")
599
+ group_has_new = False
600
+ for requested_handler in requested:
601
+ if not isinstance(requested_handler, Mapping):
602
+ return HookMergeResult("diagnostic", "invalid hook handler; unchanged")
603
+ command = requested_handler.get("command")
604
+ if not isinstance(command, str) or not command:
605
+ return HookMergeResult("diagnostic", "hook command is invalid; unchanged")
606
+ matching, conflict = _hook_command_state(existing_items, command, event, container_key)
607
+ if conflict:
608
+ return HookMergeResult("diagnostic", "existing memleaf hook conflicts; unchanged")
609
+ if not matching:
610
+ group_has_new = True
611
+ if group_has_new:
612
+ existing_items.append(dict(handler))
613
+ updated = True
614
+ container[event] = existing_items
615
+
616
+ if not updated:
617
+ return HookMergeResult("already_configured", "memleaf hooks are already configured")
618
+ if container_key is not None:
619
+ document[container_key] = container
620
+ if dry_run:
621
+ return HookMergeResult("would_configure", "would merge memleaf lifecycle hooks")
622
+
623
+ backup: Path | None = None
624
+ try:
625
+ backup = make_backup(target)
626
+ payload = (
627
+ json.dumps(document, ensure_ascii=False, indent=2, separators=(",", ": ")) + "\n"
628
+ ).encode("utf-8")
629
+ mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
630
+ atomic_replace_bytes(target, payload, mode=mode or 0o600)
631
+ except Exception:
632
+ return HookMergeResult("failure", "hook configuration update failed; backup retained", backup_path=backup)
633
+ return HookMergeResult(
634
+ "configured",
635
+ "memleaf lifecycle hooks configured",
636
+ changed=True,
637
+ backup_path=backup,
638
+ )
639
+
640
+
641
+ def _hook_command_state(
642
+ existing_items: Sequence[Any],
643
+ command: str,
644
+ event: str,
645
+ container_key: str | None,
646
+ ) -> tuple[bool, bool]:
647
+ """Return ``(matching, conflict)`` for one requested command."""
648
+
649
+ requested_identity = _host_event_identity(command)
650
+ for item in existing_items:
651
+ handlers: Any = item.get("hooks") if isinstance(item, Mapping) and container_key == "hooks" else [item]
652
+ if not isinstance(handlers, list):
653
+ continue
654
+ for handler in handlers:
655
+ if not isinstance(handler, Mapping):
656
+ continue
657
+ current = handler.get("command")
658
+ if not isinstance(current, str):
659
+ continue
660
+ if current == command:
661
+ return True, False
662
+ if requested_identity is not None and _host_event_identity(current) == requested_identity:
663
+ return False, True
664
+ return False, False
665
+
666
+
667
+ def _host_event_identity(command: str) -> tuple[str, str] | None:
668
+ """Recognize old and current memleaf host-event command forms."""
669
+
670
+ try:
671
+ tokens = shlex.split(command)
672
+ except ValueError:
673
+ return None
674
+ for index, token in enumerate(tokens):
675
+ if token != "host-event" or index < 1 or index + 2 >= len(tokens):
676
+ continue
677
+ old_form = tokens[index - 1] == "memleaf"
678
+ module_form = index >= 2 and tokens[index - 1] == "memleaf.cli" and tokens[index - 2] == "-m"
679
+ if old_form or module_form:
680
+ host, hook_event = tokens[index + 1], tokens[index + 2]
681
+ if host in ("codex", "antigravity") and hook_event:
682
+ return host, hook_event
683
+ return None
684
+
685
+
686
+ def _fsync_file(path: Path) -> None:
687
+ try:
688
+ descriptor = os.open(str(path), os.O_RDONLY)
689
+ except OSError:
690
+ return
691
+ try:
692
+ try:
693
+ os.fsync(descriptor)
694
+ except OSError:
695
+ pass
696
+ finally:
697
+ os.close(descriptor)
698
+
699
+
700
+ def _fsync_directory(directory: Path) -> None:
701
+ try:
702
+ descriptor = os.open(str(directory), os.O_RDONLY)
703
+ except OSError:
704
+ return
705
+ try:
706
+ try:
707
+ os.fsync(descriptor)
708
+ except OSError:
709
+ pass
710
+ finally:
711
+ os.close(descriptor)
712
+
713
+
714
+ def result_from_detection(
715
+ detection: Detection,
716
+ *,
717
+ status: str,
718
+ reason: str,
719
+ changed: bool = False,
720
+ backup_path: Path | str | None = None,
721
+ command: Sequence[str] | None = None,
722
+ dry_run: bool = False,
723
+ hook_trust_status: str | None = None,
724
+ hook_activation_status: str | None = None,
725
+ hook_definition_hash: str | None = None,
726
+ user_action_required: bool | None = None,
727
+ user_action: str | None = None,
728
+ ) -> ConfigureResult:
729
+ return ConfigureResult(
730
+ agent=detection.agent,
731
+ detected=detection.detected,
732
+ confidence=detection.confidence,
733
+ reason=reason,
734
+ executable=detection.executable,
735
+ config_path=detection.config_path,
736
+ status=status,
737
+ changed=changed,
738
+ backup_path=str(backup_path) if backup_path is not None else None,
739
+ command=list(command) if command is not None else None,
740
+ dry_run=dry_run,
741
+ hook_trust_status=hook_trust_status,
742
+ hook_activation_status=hook_activation_status,
743
+ hook_definition_hash=hook_definition_hash,
744
+ user_action_required=user_action_required,
745
+ user_action=user_action,
746
+ )