amkernel 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,597 @@
1
+ """Global Runtime installation and project-scoped Agent activation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import tomllib
12
+ from collections.abc import Callable, Sequence
13
+ from contextlib import contextmanager
14
+ from dataclasses import asdict, dataclass, replace
15
+ from importlib import metadata, resources
16
+ from pathlib import Path
17
+ from typing import Any, Literal, cast
18
+ from urllib.parse import urlparse
19
+
20
+ import tomlkit
21
+ from tomlkit.items import AbstractTable, InlineTable
22
+
23
+ from amkernel import __version__
24
+
25
+ Client = Literal["omp", "pi", "codex"]
26
+ MAX_COMMAND_BYTES = 65_536
27
+ MAX_JSON_BYTES = 262_144
28
+ _SCHEMA = "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"
29
+ _BEGIN = "# BEGIN AMKERNEL MANAGED MCP"
30
+ _END = "# END AMKERNEL MANAGED MCP"
31
+
32
+
33
+ class DistributionError(RuntimeError):
34
+ """A user-actionable installation or configuration failure."""
35
+
36
+
37
+ def _remove_codex_amkernel(text: str) -> str:
38
+ try:
39
+ document = tomlkit.parse(text)
40
+ except tomlkit.exceptions.ParseError as error:
41
+ raise DistributionError(f"cannot parse Codex TOML: {error}") from error
42
+ servers = document.get("mcp_servers")
43
+ if isinstance(servers, AbstractTable):
44
+ servers.pop("amkernel", None)
45
+ if isinstance(servers, InlineTable):
46
+ replacement = tomlkit.table()
47
+ replacement.update(servers)
48
+ document["mcp_servers"] = replacement
49
+ return tomlkit.dumps(document)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CommandResult:
54
+ argv: tuple[str, ...]
55
+ returncode: int
56
+ stdout: str
57
+ stderr: str
58
+ truncated: bool
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ProjectStatus:
63
+ project: str
64
+ client: str | None
65
+ runtime_executable: str | None
66
+ runtime_installed: bool
67
+ skill_installed: bool
68
+ mcp_configured: bool
69
+ restart_required: bool
70
+ detail: str | None = None
71
+ runtime_version: str | None = None
72
+ skill_version: str | None = None
73
+ known_project_references: tuple[str, ...] = ()
74
+ references_truncated: bool = False
75
+
76
+ def to_dict(self) -> dict[str, object]:
77
+ return asdict(self)
78
+
79
+
80
+ Runner = Callable[[Sequence[str]], CommandResult]
81
+
82
+
83
+ def run_command(argv: Sequence[str]) -> CommandResult:
84
+ """Run one argv-only command and retain bounded diagnostic output."""
85
+ completed = subprocess.run(argv, capture_output=True, check=False)
86
+ stdout, stdout_cut = _bounded_decode(completed.stdout)
87
+ stderr, stderr_cut = _bounded_decode(completed.stderr)
88
+ return CommandResult(
89
+ tuple(argv), completed.returncode, stdout, stderr, stdout_cut or stderr_cut
90
+ )
91
+
92
+
93
+ def _bounded_decode(value: bytes) -> tuple[str, bool]:
94
+ truncated = len(value) > MAX_COMMAND_BYTES
95
+ return value[:MAX_COMMAND_BYTES].decode("utf-8", errors="replace"), truncated
96
+
97
+
98
+ def _require_success(result: CommandResult, action: str) -> CommandResult:
99
+ if result.returncode:
100
+ detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
101
+ raise DistributionError(f"{action} failed: {detail}")
102
+ return result
103
+
104
+
105
+ def _json_bytes(value: object) -> bytes:
106
+ payload = json.dumps(value, indent=2, sort_keys=True).encode("utf-8") + b"\n"
107
+ if len(payload) > MAX_JSON_BYTES:
108
+ raise DistributionError(f"managed JSON exceeds {MAX_JSON_BYTES} bytes")
109
+ return payload
110
+
111
+
112
+ def _load_json_object(path: Path) -> dict[str, Any]:
113
+ if not path.exists():
114
+ return {}
115
+ try:
116
+ value = json.loads(path.read_text(encoding="utf-8"))
117
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
118
+ raise DistributionError(f"cannot read valid JSON from {path}: {error}") from error
119
+ if not isinstance(value, dict):
120
+ raise DistributionError(f"expected a JSON object in {path}")
121
+ return value
122
+
123
+
124
+ def _atomic_write(path: Path, content: bytes) -> None:
125
+ try:
126
+ if path.is_file() and path.read_bytes() == content:
127
+ return
128
+ except OSError:
129
+ pass
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ temporary = path.with_name(f".{path.name}.amkernel-{os.getpid()}.tmp")
132
+ try:
133
+ with temporary.open("xb") as stream:
134
+ stream.write(content)
135
+ stream.flush()
136
+ os.fsync(stream.fileno())
137
+ os.replace(temporary, path)
138
+ finally:
139
+ temporary.unlink(missing_ok=True)
140
+
141
+
142
+ @contextmanager
143
+ def _exclusive_lock(lock: Path) -> Any:
144
+ lock.parent.mkdir(parents=True, exist_ok=True)
145
+ descriptor = os.open(lock, os.O_CREAT | os.O_RDWR, 0o600)
146
+ try:
147
+ try:
148
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
149
+ except BlockingIOError as error:
150
+ raise DistributionError(f"another AMKernel update is active: {lock}") from error
151
+ os.ftruncate(descriptor, 0)
152
+ os.write(descriptor, f"{os.getpid()}\n".encode())
153
+ yield
154
+ finally:
155
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
156
+ os.close(descriptor)
157
+
158
+
159
+ class ProjectTransaction:
160
+ """Snapshot and restore only paths managed by one project operation."""
161
+
162
+ def __init__(self, project: Path) -> None:
163
+ self.project = project
164
+ self._snapshots: dict[Path, bytes | None] = {}
165
+
166
+ def snapshot(self, path: Path) -> None:
167
+ if path not in self._snapshots:
168
+ self._snapshots[path] = path.read_bytes() if path.is_file() else None
169
+
170
+ def rollback(self) -> None:
171
+ for path, content in reversed(tuple(self._snapshots.items())):
172
+ if content is None:
173
+ path.unlink(missing_ok=True)
174
+ else:
175
+ _atomic_write(path, content)
176
+ skill = self.project / ".agents" / "skills" / "amkernel"
177
+ if not any(
178
+ path.is_relative_to(skill) and content is not None
179
+ for path, content in self._snapshots.items()
180
+ ):
181
+ shutil.rmtree(skill, ignore_errors=True)
182
+
183
+
184
+ class DistributionManager:
185
+ """Own the global Runtime package and one project's thin Agent attachment."""
186
+
187
+ def __init__(
188
+ self,
189
+ project: Path,
190
+ *,
191
+ runner: Runner = run_command,
192
+ data_home: Path | None = None,
193
+ ) -> None:
194
+ self.project = project.expanduser().resolve()
195
+ self.runner = runner
196
+ configured_home = os.environ.get("AMKERNEL_DATA_HOME")
197
+ self.data_home = (
198
+ data_home.expanduser().resolve()
199
+ if data_home is not None
200
+ else Path(configured_home).expanduser().resolve()
201
+ if configured_home
202
+ else Path.home() / ".local" / "share" / "amkernel"
203
+ )
204
+ if not self.project.is_dir():
205
+ raise DistributionError(f"project directory does not exist: {self.project}")
206
+
207
+ def select_client(self, requested: str) -> Client:
208
+ if requested in {"omp", "pi", "codex"}:
209
+ return cast(Client, requested)
210
+ if requested != "auto":
211
+ raise DistributionError(f"unsupported client: {requested}")
212
+ candidates: tuple[tuple[Client, Path], ...] = (
213
+ ("omp", self.project / ".omp"),
214
+ ("pi", self.project / ".pi"),
215
+ ("codex", self.project / ".codex"),
216
+ )
217
+ marked = [client for client, marker in candidates if marker.exists()]
218
+ if len(marked) == 1:
219
+ return marked[0]
220
+ if len(marked) > 1:
221
+ raise DistributionError(
222
+ "multiple Coding Agents detected for this project; pass --client omp, pi, or codex"
223
+ )
224
+ supported_clients: tuple[Client, ...] = ("omp", "pi", "codex")
225
+ installed = [client for client in supported_clients if shutil.which(client)]
226
+ if len(installed) == 1:
227
+ return installed[0]
228
+ if len(installed) > 1:
229
+ raise DistributionError(
230
+ "multiple Coding Agents detected; pass --client omp, pi, or codex"
231
+ )
232
+ raise DistributionError(
233
+ "no supported Coding Agent detected; pass --client omp, pi, or codex"
234
+ )
235
+
236
+ def uv_tool_bin(self) -> Path:
237
+ uv = shutil.which("uv")
238
+ if not uv:
239
+ raise DistributionError("uv is required; install it from https://docs.astral.sh/uv/")
240
+ result = _require_success(self.runner((uv, "tool", "dir", "--bin")), "locating uv tool bin")
241
+ value = result.stdout.strip()
242
+ if not value:
243
+ raise DistributionError("uv returned an empty tool bin directory")
244
+ return Path(value).expanduser().resolve()
245
+
246
+ def runtime_executable(self) -> Path:
247
+ name = "amkernel.exe" if os.name == "nt" else "amkernel"
248
+ return self.uv_tool_bin() / name
249
+
250
+ def _package_spec(self) -> str:
251
+ direct_url = metadata.distribution("amkernel").read_text("direct_url.json")
252
+ if direct_url:
253
+ try:
254
+ url = json.loads(direct_url)["url"]
255
+ except (KeyError, TypeError, json.JSONDecodeError):
256
+ url = None
257
+ if isinstance(url, str) and urlparse(url).scheme == "file":
258
+ return url
259
+ return "amkernel"
260
+
261
+ def install_runtime(self) -> Path:
262
+ uv = shutil.which("uv")
263
+ if not uv:
264
+ raise DistributionError("uv is required; install it from https://docs.astral.sh/uv/")
265
+ argv = (
266
+ uv,
267
+ "tool",
268
+ "install",
269
+ self._package_spec(),
270
+ "--with",
271
+ "Appium-Python-Client>=6,<7",
272
+ )
273
+ _require_success(self.runner(argv), "installing the global AMKernel Runtime")
274
+ executable = self.runtime_executable()
275
+ if not executable.is_file():
276
+ raise DistributionError(
277
+ f"uv completed but the AMKernel executable is missing: {executable}"
278
+ )
279
+ return executable
280
+
281
+ def _config_path(self, client: Client) -> Path:
282
+ if client == "omp":
283
+ return self.project / ".omp" / "mcp.json"
284
+ if client == "pi":
285
+ return self.project / ".pi" / "mcp.json"
286
+ return self.project / ".codex" / "config.toml"
287
+
288
+ def _project_lock_path(self) -> Path:
289
+ digest = hashlib.sha256(str(self.project).encode()).hexdigest()
290
+ return self.data_home / "locks" / f"project-{digest}.lock"
291
+
292
+ def planned_changes(self, client: Client) -> tuple[str, ...]:
293
+ return (
294
+ f"Runtime: {self.runtime_executable()}",
295
+ f"Skill: {self.project / '.agents' / 'skills' / 'amkernel'}",
296
+ f"MCP ({client}): {self._config_path(client)}",
297
+ )
298
+
299
+ def _skill_source(self) -> Any:
300
+ return resources.files("amkernel").joinpath("skill")
301
+
302
+ def _skill_files(self) -> tuple[str, ...]:
303
+ return (
304
+ "SKILL.md",
305
+ "references/official-sources.json",
306
+ "references/official-skills.json",
307
+ "references/amkernel-appium.md",
308
+ "scripts/verify_official_skills.py",
309
+ )
310
+
311
+ def _packaged_skill_digests(self) -> dict[str, str]:
312
+ source = self._skill_source()
313
+ return {
314
+ relative: hashlib.sha256(source.joinpath(relative).read_bytes()).hexdigest()
315
+ for relative in self._skill_files()
316
+ }
317
+
318
+ def _install_skill(self, transaction: ProjectTransaction) -> None:
319
+ destination = self.project / ".agents" / "skills" / "amkernel"
320
+ source = self._skill_source()
321
+ digests = self._packaged_skill_digests()
322
+ for relative in self._skill_files():
323
+ target = destination / relative
324
+ transaction.snapshot(target)
325
+ target.parent.mkdir(parents=True, exist_ok=True)
326
+ _atomic_write(target, source.joinpath(relative).read_bytes())
327
+ metadata_target = destination / ".amkernel-metadata.json"
328
+ transaction.snapshot(metadata_target)
329
+ _atomic_write(
330
+ metadata_target,
331
+ _json_bytes({"runtimeVersion": __version__, "files": digests}),
332
+ )
333
+
334
+ def _write_json_client(
335
+ self, client: Client, executable: Path, transaction: ProjectTransaction
336
+ ) -> None:
337
+ path = self._config_path(client)
338
+ transaction.snapshot(path)
339
+ value = _load_json_object(path)
340
+ servers = value.setdefault("mcpServers", {})
341
+ if not isinstance(servers, dict):
342
+ raise DistributionError(f"mcpServers must be an object in {path}")
343
+ servers["amkernel"] = {
344
+ "type": "stdio",
345
+ "command": str(executable),
346
+ "cwd": str(self.project),
347
+ }
348
+ if client == "omp":
349
+ value.setdefault("$schema", _SCHEMA)
350
+ _atomic_write(path, _json_bytes(value))
351
+
352
+ def _write_codex(self, executable: Path, transaction: ProjectTransaction) -> None:
353
+ path = self._config_path("codex")
354
+ transaction.snapshot(path)
355
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
356
+ try:
357
+ tomllib.loads(text)
358
+ except tomllib.TOMLDecodeError as error:
359
+ raise DistributionError(f"cannot read valid TOML from {path}: {error}") from error
360
+ if _BEGIN in text or _END in text:
361
+ if (
362
+ text.count(_BEGIN) != 1
363
+ or text.count(_END) != 1
364
+ or text.index(_BEGIN) > text.index(_END)
365
+ ):
366
+ raise DistributionError(f"invalid AMKernel managed block in {path}")
367
+ prefix, rest = text.split(_BEGIN, 1)
368
+ _, suffix = rest.split(_END, 1)
369
+ text = prefix.rstrip() + "\n" + suffix.lstrip("\n")
370
+ text = _remove_codex_amkernel(text).rstrip() + ("\n" if text.strip() else "")
371
+ block = (
372
+ f"{_BEGIN}\n[mcp_servers.amkernel]\n"
373
+ f"command = {json.dumps(str(executable))}\n"
374
+ f"cwd = {json.dumps(str(self.project))}\n{_END}\n"
375
+ )
376
+ updated = text.rstrip() + ("\n\n" if text.strip() else "") + block
377
+ try:
378
+ tomllib.loads(updated)
379
+ except tomllib.TOMLDecodeError as error:
380
+ raise DistributionError(f"generated invalid TOML for {path}: {error}") from error
381
+ _atomic_write(path, updated.encode("utf-8"))
382
+
383
+ def _registry_path(self) -> Path:
384
+ return self.data_home / "projects.json"
385
+
386
+ def known_projects(self) -> dict[str, str]:
387
+ value = _load_json_object(self._registry_path())
388
+ projects = value.get("projects", {})
389
+ if not isinstance(projects, dict) or not all(
390
+ isinstance(path, str) and isinstance(client, str) for path, client in projects.items()
391
+ ):
392
+ raise DistributionError(f"invalid project registry: {self._registry_path()}")
393
+ return dict(projects)
394
+
395
+ def _set_project_registration(self, client: Client | None) -> None:
396
+ path = self._registry_path()
397
+ with _exclusive_lock(path.with_suffix(".lock")):
398
+ value = _load_json_object(path)
399
+ projects = value.setdefault("projects", {})
400
+ if not isinstance(projects, dict):
401
+ raise DistributionError(f"invalid project registry: {path}")
402
+ if client is None:
403
+ projects.pop(str(self.project), None)
404
+ else:
405
+ projects[str(self.project)] = client
406
+ _atomic_write(path, _json_bytes(value))
407
+
408
+ def configure_project(self, client: Client, executable: Path) -> ProjectStatus:
409
+ with _exclusive_lock(self._project_lock_path()):
410
+ transaction = ProjectTransaction(self.project)
411
+ try:
412
+ self._install_skill(transaction)
413
+ if client == "codex":
414
+ self._write_codex(executable, transaction)
415
+ else:
416
+ self._write_json_client(client, executable, transaction)
417
+ self._set_project_registration(client)
418
+ except BaseException:
419
+ transaction.rollback()
420
+ raise
421
+ return self.status(client)
422
+
423
+ def install(self, requested_client: str) -> ProjectStatus:
424
+ client = self.select_client(requested_client)
425
+ executable = self.install_runtime()
426
+ return self.configure_project(client, executable)
427
+
428
+ def status(self, client: Client | None = None) -> ProjectStatus:
429
+ if client is None:
430
+ try:
431
+ client = self.select_client("auto")
432
+ except DistributionError as error:
433
+ return ProjectStatus(
434
+ str(self.project), None, None, False, False, False, False, str(error)
435
+ )
436
+ try:
437
+ executable = self.runtime_executable()
438
+ except DistributionError as error:
439
+ return ProjectStatus(
440
+ str(self.project), client, None, False, False, False, False, str(error)
441
+ )
442
+ skill = self.project / ".agents" / "skills" / "amkernel" / "SKILL.md"
443
+ metadata_path = skill.parent / ".amkernel-metadata.json"
444
+ configured = self._is_configured(client, executable)
445
+ installed = executable.is_file()
446
+ runtime_version = None
447
+ if installed:
448
+ result = self.runner((str(executable), "--version"))
449
+ if not result.returncode and result.stdout.strip():
450
+ runtime_version = result.stdout.strip().rsplit(maxsplit=1)[-1]
451
+ skill_version = None
452
+ skill_digest_ok = False
453
+ try:
454
+ metadata_value = _load_json_object(metadata_path)
455
+ recorded_files = metadata_value.get("files")
456
+ recorded_version = metadata_value.get("runtimeVersion")
457
+ if isinstance(recorded_version, str):
458
+ skill_version = recorded_version
459
+ packaged_digests = self._packaged_skill_digests()
460
+ skill_digest_ok = (
461
+ isinstance(recorded_files, dict)
462
+ and recorded_files == packaged_digests
463
+ and all(
464
+ (skill.parent / relative).is_file()
465
+ and hashlib.sha256((skill.parent / relative).read_bytes()).hexdigest() == digest
466
+ for relative, digest in packaged_digests.items()
467
+ )
468
+ )
469
+ except (DistributionError, OSError):
470
+ pass
471
+ healthy = (
472
+ installed
473
+ and skill_digest_ok
474
+ and configured
475
+ and runtime_version is not None
476
+ and runtime_version == skill_version
477
+ )
478
+ detail = (
479
+ None if healthy else "run `amkernel install` to repair Runtime, Skill, or MCP drift"
480
+ )
481
+ return ProjectStatus(
482
+ str(self.project),
483
+ client,
484
+ str(executable),
485
+ installed,
486
+ skill.is_file(),
487
+ configured,
488
+ healthy,
489
+ detail,
490
+ runtime_version,
491
+ skill_version,
492
+ )
493
+
494
+ def _is_configured(self, client: Client, executable: Path) -> bool:
495
+ path = self._config_path(client)
496
+ try:
497
+ if client == "codex":
498
+ value = tomllib.loads(path.read_text(encoding="utf-8"))
499
+ entry = value.get("mcp_servers", {}).get("amkernel", {})
500
+ else:
501
+ entry = _load_json_object(path).get("mcpServers", {}).get("amkernel", {})
502
+ return (
503
+ isinstance(entry, dict)
504
+ and entry.get("command") == str(executable)
505
+ and entry.get("cwd") == str(self.project)
506
+ )
507
+ except (DistributionError, OSError, UnicodeError, tomllib.TOMLDecodeError, AttributeError):
508
+ return False
509
+
510
+ def doctor(self, client: Client) -> ProjectStatus:
511
+ status = self.status(client)
512
+ if not (status.runtime_installed and status.skill_installed and status.mcp_configured):
513
+ return status
514
+ executable = Path(status.runtime_executable or "")
515
+ result = self.runner((str(executable), "--version"))
516
+ if result.returncode:
517
+ return replace(
518
+ status,
519
+ detail=result.stderr or result.stdout,
520
+ restart_required=False,
521
+ )
522
+ return status
523
+
524
+ def update(self, client: Client) -> ProjectStatus:
525
+ uv = shutil.which("uv")
526
+ if not uv:
527
+ raise DistributionError("uv is required to update the global AMKernel Runtime")
528
+ _require_success(
529
+ self.runner((uv, "tool", "upgrade", "amkernel")),
530
+ "updating the global AMKernel Runtime",
531
+ )
532
+ executable = self.runtime_executable()
533
+ _require_success(
534
+ self.runner(
535
+ (
536
+ str(executable),
537
+ "install",
538
+ "--client",
539
+ client,
540
+ "--scope",
541
+ "project",
542
+ "--project",
543
+ str(self.project),
544
+ "--yes",
545
+ "--json",
546
+ )
547
+ ),
548
+ "synchronizing the project from the updated Runtime",
549
+ )
550
+ return self.status(client)
551
+
552
+ def uninstall_project(self, client: Client) -> ProjectStatus:
553
+ with _exclusive_lock(self._project_lock_path()):
554
+ path = self._config_path(client)
555
+ transaction = ProjectTransaction(self.project)
556
+ transaction.snapshot(path)
557
+ skill = self.project / ".agents" / "skills" / "amkernel"
558
+ for relative in (
559
+ "SKILL.md",
560
+ ".amkernel-metadata.json",
561
+ "references/official-sources.json",
562
+ "references/official-skills.json",
563
+ "references/amkernel-appium.md",
564
+ "scripts/verify_official_skills.py",
565
+ ):
566
+ transaction.snapshot(skill / relative)
567
+ try:
568
+ if client == "codex" and path.exists():
569
+ text = path.read_text(encoding="utf-8")
570
+ if _BEGIN in text and _END in text:
571
+ prefix, rest = text.split(_BEGIN, 1)
572
+ _, suffix = rest.split(_END, 1)
573
+ updated = prefix.rstrip() + "\n" + suffix.lstrip("\n")
574
+ else:
575
+ updated = text
576
+ updated = _remove_codex_amkernel(updated).rstrip() + "\n"
577
+ _atomic_write(path, updated.encode())
578
+ elif path.exists():
579
+ value = _load_json_object(path)
580
+ servers = value.get("mcpServers")
581
+ if isinstance(servers, dict):
582
+ servers.pop("amkernel", None)
583
+ _atomic_write(path, _json_bytes(value))
584
+ shutil.rmtree(skill, ignore_errors=True)
585
+ self._set_project_registration(None)
586
+ except BaseException:
587
+ transaction.rollback()
588
+ raise
589
+ return self.status(client)
590
+
591
+ def uninstall_global(self) -> None:
592
+ uv = shutil.which("uv")
593
+ if not uv:
594
+ raise DistributionError("uv is required to remove the global AMKernel Runtime")
595
+ _require_success(
596
+ self.runner((uv, "tool", "uninstall", "amkernel")), "removing global AMKernel Runtime"
597
+ )