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.
- amkernel/__init__.py +7 -0
- amkernel/__main__.py +5 -0
- amkernel/artifact_store.py +1363 -0
- amkernel/cli.py +172 -0
- amkernel/distribution.py +597 -0
- amkernel/output.py +444 -0
- amkernel/protocol.py +1464 -0
- amkernel/py.typed +0 -0
- amkernel/runner.py +1222 -0
- amkernel/runtime.py +581 -0
- amkernel/server.py +774 -0
- amkernel/skill/SKILL.md +124 -0
- amkernel/skill/references/amkernel-appium.md +46 -0
- amkernel/skill/references/official-skills.json +232 -0
- amkernel/skill/references/official-sources.json +21 -0
- amkernel/skill/scripts/verify_official_skills.py +252 -0
- amkernel/structured_output.py +328 -0
- amkernel/supervisor.py +1453 -0
- amkernel/workspace.py +305 -0
- amkernel-0.1.0.dist-info/METADATA +105 -0
- amkernel-0.1.0.dist-info/RECORD +23 -0
- amkernel-0.1.0.dist-info/WHEEL +4 -0
- amkernel-0.1.0.dist-info/entry_points.txt +3 -0
amkernel/distribution.py
ADDED
|
@@ -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
|
+
)
|