subtask-mcp 0.2.3__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.
- subtask/__init__.py +3 -0
- subtask/admission.py +254 -0
- subtask/capacity.py +90 -0
- subtask/cli.py +183 -0
- subtask/config.py +59 -0
- subtask/core.py +603 -0
- subtask/doctor.py +120 -0
- subtask/engines/__init__.py +1 -0
- subtask/engines/base.py +297 -0
- subtask/engines/claude_api.py +204 -0
- subtask/engines/codex.py +182 -0
- subtask/engines/demo.py +50 -0
- subtask/engines/dsh.py +96 -0
- subtask/engines/kimi.py +75 -0
- subtask/engines/opencode.py +87 -0
- subtask/engines/registry.py +124 -0
- subtask/events.py +105 -0
- subtask/evidence.py +103 -0
- subtask/fastserver.py +272 -0
- subtask/gc.py +99 -0
- subtask/install/__init__.py +1 -0
- subtask/install/clients.py +355 -0
- subtask/install/commands.py +122 -0
- subtask/install/doctor.py +112 -0
- subtask/install/fileops.py +69 -0
- subtask/install/installer.py +204 -0
- subtask/install/ownership.py +44 -0
- subtask/install/tomledit.py +44 -0
- subtask/jobs.py +256 -0
- subtask/ledger.py +268 -0
- subtask/narrate.py +134 -0
- subtask/policy.py +58 -0
- subtask/procid.py +97 -0
- subtask/progress.py +152 -0
- subtask/redact.py +61 -0
- subtask/roi.py +84 -0
- subtask/router.py +202 -0
- subtask/runners.py +99 -0
- subtask/schemacheck.py +131 -0
- subtask/server.py +192 -0
- subtask/skill/SKILL.md +96 -0
- subtask/telemetry.py +76 -0
- subtask/tools.json +237 -0
- subtask/worker.py +278 -0
- subtask_mcp-0.2.3.dist-info/METADATA +164 -0
- subtask_mcp-0.2.3.dist-info/RECORD +50 -0
- subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
- subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
- subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
- subtask_mcp-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Where each client keeps its MCP entry and skills, and how to read and change it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import tomledit
|
|
16
|
+
from .fileops import atomic_write, read_bytes
|
|
17
|
+
|
|
18
|
+
NAME = "subtask"
|
|
19
|
+
CODEX_TOOL_TIMEOUT_SEC = 660
|
|
20
|
+
KIMI_TOOL_TIMEOUT_MS = 660_000
|
|
21
|
+
NATIVE_TIMEOUT_S = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InstallError(RuntimeError):
|
|
25
|
+
"""A step failed; the message is safe to show."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Step:
|
|
30
|
+
"""One planned change: a native command (argv) or a file edit (description)."""
|
|
31
|
+
|
|
32
|
+
kind: str # "run" | "edit" (set a key) | "unset" (remove a key)
|
|
33
|
+
target: str
|
|
34
|
+
argv: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
def describe(self) -> str:
|
|
37
|
+
if self.kind == "run":
|
|
38
|
+
return "run: " + " ".join(_quote(a) for a in self.argv)
|
|
39
|
+
return f"{self.kind}: {self.target}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _quote(arg: str) -> str:
|
|
43
|
+
import shlex
|
|
44
|
+
|
|
45
|
+
return shlex.quote(arg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_native(argv: list[str]) -> None:
|
|
49
|
+
try:
|
|
50
|
+
proc = subprocess.run(
|
|
51
|
+
argv,
|
|
52
|
+
stdin=subprocess.DEVNULL,
|
|
53
|
+
check=False,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
timeout=NATIVE_TIMEOUT_S,
|
|
57
|
+
)
|
|
58
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
59
|
+
raise InstallError(f"{argv[0]} failed: {exc}") from exc
|
|
60
|
+
if proc.returncode != 0:
|
|
61
|
+
tail = (proc.stderr or proc.stdout).strip().splitlines()[-3:]
|
|
62
|
+
raise InstallError(f"{' '.join(argv[:3])} exited {proc.returncode}: {' | '.join(tail)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def strip_jsonc(text: str) -> str:
|
|
66
|
+
"""JSON with comments and trailing commas (OpenCode's opencode.jsonc) -> plain JSON text."""
|
|
67
|
+
out, i, n, in_str = [], 0, len(text), False
|
|
68
|
+
while i < n:
|
|
69
|
+
ch = text[i]
|
|
70
|
+
if in_str:
|
|
71
|
+
out.append(ch)
|
|
72
|
+
if ch == "\\":
|
|
73
|
+
out.append(text[i + 1 : i + 2])
|
|
74
|
+
i += 2
|
|
75
|
+
continue
|
|
76
|
+
if ch == '"':
|
|
77
|
+
in_str = False
|
|
78
|
+
elif ch == '"':
|
|
79
|
+
in_str = True
|
|
80
|
+
out.append(ch)
|
|
81
|
+
elif text.startswith("//", i):
|
|
82
|
+
while i < n and text[i] != "\n":
|
|
83
|
+
i += 1
|
|
84
|
+
continue
|
|
85
|
+
elif text.startswith("/*", i):
|
|
86
|
+
end = text.find("*/", i + 2)
|
|
87
|
+
i = n if end < 0 else end + 2
|
|
88
|
+
continue
|
|
89
|
+
else:
|
|
90
|
+
out.append(ch)
|
|
91
|
+
i += 1
|
|
92
|
+
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _has_comments(raw: bytes) -> bool:
|
|
96
|
+
text = raw.decode("utf-8", errors="replace")
|
|
97
|
+
return strip_jsonc(text) != re.sub(r",(\s*[}\]])", r"\1", text)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
101
|
+
raw = read_bytes(path)
|
|
102
|
+
if raw is None or not raw.strip():
|
|
103
|
+
return {}
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(strip_jsonc(raw.decode("utf-8")))
|
|
106
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
107
|
+
raise InstallError(f"{path} is not plain JSON ({exc}); edit it by hand") from exc
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise InstallError(f"{path}: top level is not an object")
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _dig(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
|
|
114
|
+
for k in keys:
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
return None
|
|
117
|
+
data = data.get(k) # type: ignore[assignment]
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def json_set(path: Path, keys: tuple[str, ...], value: Any | None) -> None:
|
|
122
|
+
"""Set (or with None remove) one nested key; everything else is kept as parsed.
|
|
123
|
+
|
|
124
|
+
A file with comments is never rewritten (the comments would be lost): edit it by hand.
|
|
125
|
+
"""
|
|
126
|
+
raw = read_bytes(path)
|
|
127
|
+
data = _load_json(path)
|
|
128
|
+
if value is None and _dig(data, keys) is None:
|
|
129
|
+
return # nothing to remove: the file is not touched
|
|
130
|
+
if raw is not None and _has_comments(raw):
|
|
131
|
+
raise InstallError(
|
|
132
|
+
f"{path} has comments; rewriting it would drop them: change {'.'.join(keys)} by hand"
|
|
133
|
+
)
|
|
134
|
+
node = data
|
|
135
|
+
for k in keys[:-1]:
|
|
136
|
+
child = node.get(k)
|
|
137
|
+
if child is None:
|
|
138
|
+
if value is None:
|
|
139
|
+
return
|
|
140
|
+
child = {}
|
|
141
|
+
if not isinstance(child, dict):
|
|
142
|
+
raise InstallError(f"{path}: '{k}' is not an object")
|
|
143
|
+
node[k] = dict(child)
|
|
144
|
+
node = node[k]
|
|
145
|
+
if value is None:
|
|
146
|
+
node.pop(keys[-1], None)
|
|
147
|
+
else:
|
|
148
|
+
node[keys[-1]] = value
|
|
149
|
+
atomic_write(path, (json.dumps(data, indent=2) + "\n").encode())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Client:
|
|
153
|
+
name = ""
|
|
154
|
+
binary = ""
|
|
155
|
+
scopes: tuple[str, ...] = ("user", "project")
|
|
156
|
+
|
|
157
|
+
def __init__(self, cwd: Path | None = None) -> None:
|
|
158
|
+
self.cwd = cwd or Path.cwd()
|
|
159
|
+
|
|
160
|
+
# --- detection
|
|
161
|
+
def exe(self) -> str | None:
|
|
162
|
+
return shutil.which(self.binary)
|
|
163
|
+
|
|
164
|
+
def version(self) -> str | None:
|
|
165
|
+
exe = self.exe()
|
|
166
|
+
if not exe:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
out = subprocess.run(
|
|
170
|
+
[exe, "--version"],
|
|
171
|
+
stdin=subprocess.DEVNULL,
|
|
172
|
+
check=False,
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
timeout=20,
|
|
176
|
+
)
|
|
177
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
178
|
+
return "unknown"
|
|
179
|
+
lines = (out.stdout or out.stderr).strip().splitlines()
|
|
180
|
+
return lines[0][:80] if lines else "unknown"
|
|
181
|
+
|
|
182
|
+
def check_scope(self, scope: str) -> None:
|
|
183
|
+
if scope not in self.scopes:
|
|
184
|
+
raise InstallError(f"{self.name} does not support --scope {scope}")
|
|
185
|
+
|
|
186
|
+
# --- per client
|
|
187
|
+
def config_path(self, scope: str) -> Path:
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
def read_entry(self, scope: str) -> dict[str, Any] | None:
|
|
191
|
+
raise NotImplementedError
|
|
192
|
+
|
|
193
|
+
def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
|
|
194
|
+
raise NotImplementedError
|
|
195
|
+
|
|
196
|
+
def remove_steps(self, scope: str) -> list[Step]:
|
|
197
|
+
raise NotImplementedError
|
|
198
|
+
|
|
199
|
+
def apply(self, step: Step, scope: str, argv: list[str]) -> None:
|
|
200
|
+
if step.kind == "run":
|
|
201
|
+
run_native(list(step.argv))
|
|
202
|
+
else:
|
|
203
|
+
raise InstallError(f"unknown step {step.describe()}")
|
|
204
|
+
|
|
205
|
+
def skill_path(self, scope: str) -> Path:
|
|
206
|
+
raise NotImplementedError
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def target(entry: dict[str, Any] | None) -> list[str] | None:
|
|
210
|
+
"""The launched argv of an entry, comparable across clients."""
|
|
211
|
+
if not entry:
|
|
212
|
+
return None
|
|
213
|
+
cmd = entry.get("command")
|
|
214
|
+
if isinstance(cmd, list):
|
|
215
|
+
return [str(c) for c in cmd]
|
|
216
|
+
return [str(cmd), *[str(a) for a in entry.get("args") or []]]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Claude(Client):
|
|
220
|
+
name = binary = "claude"
|
|
221
|
+
|
|
222
|
+
def config_path(self, scope: str) -> Path:
|
|
223
|
+
return Path.home() / ".claude.json" if scope == "user" else self.cwd / ".mcp.json"
|
|
224
|
+
|
|
225
|
+
def read_entry(self, scope: str) -> dict[str, Any] | None:
|
|
226
|
+
return _dig(_load_json(self.config_path(scope)), ("mcpServers", NAME))
|
|
227
|
+
|
|
228
|
+
def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
|
|
229
|
+
cmd = (self.exe() or "claude", "mcp", "add", "-s", scope, NAME, "--", *argv)
|
|
230
|
+
return [Step("run", str(self.config_path(scope)), cmd)]
|
|
231
|
+
|
|
232
|
+
def remove_steps(self, scope: str) -> list[Step]:
|
|
233
|
+
cmd = (self.exe() or "claude", "mcp", "remove", "-s", scope, NAME)
|
|
234
|
+
return [Step("run", str(self.config_path(scope)), cmd)]
|
|
235
|
+
|
|
236
|
+
def skill_path(self, scope: str) -> Path:
|
|
237
|
+
base = Path.home() if scope == "user" else self.cwd
|
|
238
|
+
return base / ".claude" / "skills" / NAME / "SKILL.md"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Codex(Client):
|
|
242
|
+
name = binary = "codex"
|
|
243
|
+
scopes = ("user",)
|
|
244
|
+
|
|
245
|
+
def home(self) -> Path:
|
|
246
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
|
247
|
+
|
|
248
|
+
def config_path(self, scope: str) -> Path:
|
|
249
|
+
return self.home() / "config.toml"
|
|
250
|
+
|
|
251
|
+
def read_entry(self, scope: str) -> dict[str, Any] | None:
|
|
252
|
+
raw = read_bytes(self.config_path(scope))
|
|
253
|
+
if not raw:
|
|
254
|
+
return None
|
|
255
|
+
try:
|
|
256
|
+
data = tomllib.loads(raw.decode())
|
|
257
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
258
|
+
raise InstallError(f"{self.config_path(scope)} is not valid TOML: {exc}") from exc
|
|
259
|
+
return _dig(data, ("mcp_servers", NAME))
|
|
260
|
+
|
|
261
|
+
def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
|
|
262
|
+
path = str(self.config_path(scope))
|
|
263
|
+
return [
|
|
264
|
+
Step("run", path, (self.exe() or "codex", "mcp", "add", NAME, "--", *argv)),
|
|
265
|
+
Step("edit", f"{path}: [mcp_servers.{NAME}] tool_timeout_sec = {CODEX_TOOL_TIMEOUT_SEC}"),
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
def remove_steps(self, scope: str) -> list[Step]:
|
|
269
|
+
path = str(self.config_path(scope))
|
|
270
|
+
return [Step("run", path, (self.exe() or "codex", "mcp", "remove", NAME))]
|
|
271
|
+
|
|
272
|
+
def apply(self, step: Step, scope: str, argv: list[str]) -> None:
|
|
273
|
+
if step.kind != "edit":
|
|
274
|
+
return super().apply(step, scope, argv)
|
|
275
|
+
path = self.config_path(scope)
|
|
276
|
+
text = (read_bytes(path) or b"").decode()
|
|
277
|
+
try:
|
|
278
|
+
new = tomledit.set_key(
|
|
279
|
+
text, f"mcp_servers.{NAME}", "tool_timeout_sec", str(CODEX_TOOL_TIMEOUT_SEC)
|
|
280
|
+
)
|
|
281
|
+
except ValueError as exc:
|
|
282
|
+
raise InstallError(f"{path}: {exc}") from exc
|
|
283
|
+
atomic_write(path, new.encode())
|
|
284
|
+
|
|
285
|
+
def skill_path(self, scope: str) -> Path:
|
|
286
|
+
return Path.home() / ".agents" / "skills" / NAME / "SKILL.md"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Kimi(Client):
|
|
290
|
+
name = binary = "kimi"
|
|
291
|
+
|
|
292
|
+
def home(self) -> Path:
|
|
293
|
+
return Path(os.environ.get("KIMI_CODE_HOME") or Path.home() / ".kimi-code")
|
|
294
|
+
|
|
295
|
+
def config_path(self, scope: str) -> Path:
|
|
296
|
+
return self.home() / "mcp.json" if scope == "user" else self.cwd / ".kimi-code" / "mcp.json"
|
|
297
|
+
|
|
298
|
+
def read_entry(self, scope: str) -> dict[str, Any] | None:
|
|
299
|
+
return _dig(_load_json(self.config_path(scope)), ("mcpServers", NAME))
|
|
300
|
+
|
|
301
|
+
def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
|
|
302
|
+
return [
|
|
303
|
+
Step("edit", f"{self.config_path(scope)}: mcpServers.{NAME} = {json.dumps(self.entry(argv))}")
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
def remove_steps(self, scope: str) -> list[Step]:
|
|
307
|
+
return [Step("unset", f"{self.config_path(scope)}: mcpServers.{NAME}")]
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def entry(argv: list[str]) -> dict[str, Any]:
|
|
311
|
+
return {"command": argv[0], "args": argv[1:], "toolTimeoutMs": KIMI_TOOL_TIMEOUT_MS}
|
|
312
|
+
|
|
313
|
+
def apply(self, step: Step, scope: str, argv: list[str]) -> None:
|
|
314
|
+
value = None if step.kind == "unset" else self.entry(argv)
|
|
315
|
+
json_set(self.config_path(scope), ("mcpServers", NAME), value)
|
|
316
|
+
|
|
317
|
+
def skill_path(self, scope: str) -> Path:
|
|
318
|
+
base = self.home() if scope == "user" else self.cwd / ".kimi-code"
|
|
319
|
+
return base / "skills" / NAME / "SKILL.md"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class OpenCode(Client):
|
|
323
|
+
name = binary = "opencode"
|
|
324
|
+
|
|
325
|
+
def config_path(self, scope: str) -> Path:
|
|
326
|
+
if scope == "project":
|
|
327
|
+
return self.cwd / "opencode.json"
|
|
328
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "opencode"
|
|
329
|
+
if not (base / "opencode.json").exists() and (base / "opencode.jsonc").exists():
|
|
330
|
+
return base / "opencode.jsonc" # what `opencode mcp add --global` writes by default
|
|
331
|
+
return base / "opencode.json"
|
|
332
|
+
|
|
333
|
+
def read_entry(self, scope: str) -> dict[str, Any] | None:
|
|
334
|
+
return _dig(_load_json(self.config_path(scope)), ("mcp", "servers", NAME))
|
|
335
|
+
|
|
336
|
+
def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
|
|
337
|
+
flags = ("--global",) if scope == "user" else ()
|
|
338
|
+
cmd = (self.exe() or "opencode", "mcp", "add", NAME, *flags, "--", *argv)
|
|
339
|
+
return [Step("run", str(self.config_path(scope)), cmd)]
|
|
340
|
+
|
|
341
|
+
def remove_steps(self, scope: str) -> list[Step]: # opencode has no `mcp remove`
|
|
342
|
+
return [Step("unset", f"{self.config_path(scope)}: mcp.servers.{NAME}")]
|
|
343
|
+
|
|
344
|
+
def apply(self, step: Step, scope: str, argv: list[str]) -> None:
|
|
345
|
+
if step.kind == "run":
|
|
346
|
+
return super().apply(step, scope, argv)
|
|
347
|
+
json_set(self.config_path(scope), ("mcp", "servers", NAME), None)
|
|
348
|
+
|
|
349
|
+
def skill_path(self, scope: str) -> Path:
|
|
350
|
+
if scope == "project":
|
|
351
|
+
return self.cwd / ".opencode" / "skills" / NAME / "SKILL.md"
|
|
352
|
+
return self.config_path("user").parent / "skills" / NAME / "SKILL.md"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
CLIENTS: dict[str, type[Client]] = {"claude": Claude, "codex": Codex, "kimi": Kimi, "opencode": OpenCode}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`subtask install | uninstall | doctor` argument parsing and output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .clients import CLIENTS, Client, InstallError
|
|
12
|
+
from .doctor import doctor
|
|
13
|
+
from .installer import install, uninstall
|
|
14
|
+
|
|
15
|
+
CHOICES = [*CLIENTS, "all"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
19
|
+
i = sub.add_parser(
|
|
20
|
+
"install", help="register the MCP server and skill in a client (plan only without --yes)"
|
|
21
|
+
)
|
|
22
|
+
u = sub.add_parser("uninstall", help="remove what `subtask install` added (plan only without --yes)")
|
|
23
|
+
d = sub.add_parser("doctor", help="check a client: app, config entry, server handshake")
|
|
24
|
+
for p in (i, u, d):
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"--client", required=p is not d, choices=CHOICES, help="doctor without --client checks engines"
|
|
27
|
+
)
|
|
28
|
+
p.add_argument("--scope", default="user", choices=["user", "project"])
|
|
29
|
+
p.add_argument("--json", action="store_true", help="machine-readable output on stdout")
|
|
30
|
+
for p in (i, u):
|
|
31
|
+
p.add_argument(
|
|
32
|
+
"--yes", action="store_true", help="apply the plan (default: print it, change nothing)"
|
|
33
|
+
)
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"--dry-run", action="store_true", help="print the plan only (the default without --yes)"
|
|
36
|
+
)
|
|
37
|
+
i.add_argument(
|
|
38
|
+
"--force", action="store_true", help="replace an existing 'subtask' entry that is not ours"
|
|
39
|
+
)
|
|
40
|
+
i.add_argument("--command", help="server command to register (default: subtask-mcp next to this python)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def server_command(override: str | None) -> list[str]:
|
|
44
|
+
if override:
|
|
45
|
+
return [override]
|
|
46
|
+
here = Path(sys.executable).parent / "subtask-mcp"
|
|
47
|
+
if here.is_file():
|
|
48
|
+
return [str(here.absolute())]
|
|
49
|
+
import shutil
|
|
50
|
+
|
|
51
|
+
found = shutil.which("subtask-mcp")
|
|
52
|
+
if not found:
|
|
53
|
+
raise InstallError("subtask-mcp not found next to this python nor on PATH; pass --command PATH")
|
|
54
|
+
return [str(Path(found).absolute())]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _selected(name: str) -> tuple[list[Client], list[str]]:
|
|
58
|
+
if name != "all":
|
|
59
|
+
return [CLIENTS[name]()], []
|
|
60
|
+
clients = [cls() for cls in CLIENTS.values()]
|
|
61
|
+
return [c for c in clients if c.exe()], [c.name for c in clients if not c.exe()]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run(a: argparse.Namespace) -> int:
|
|
65
|
+
clients, missing = _selected(a.client)
|
|
66
|
+
if a.cmd == "doctor":
|
|
67
|
+
reports = [doctor(c, a.scope) for c in clients]
|
|
68
|
+
ok = all(r.get("connects") for r in reports) and bool(reports)
|
|
69
|
+
_emit(a.json, {"action": "doctor", "clients": reports, "not_detected": missing}, _doctor_text)
|
|
70
|
+
return 0 if ok else 1
|
|
71
|
+
apply = a.yes and not a.dry_run
|
|
72
|
+
try:
|
|
73
|
+
argv = server_command(a.command) if a.cmd == "install" else []
|
|
74
|
+
except InstallError as exc:
|
|
75
|
+
_emit(a.json, {"action": a.cmd, "error": str(exc)}, lambda d: f"error: {d['error']}")
|
|
76
|
+
return 2
|
|
77
|
+
results = []
|
|
78
|
+
for c in clients:
|
|
79
|
+
if a.cmd == "install" and not c.exe():
|
|
80
|
+
results.append({"client": c.name, "scope": a.scope, "status": "skipped",
|
|
81
|
+
"steps": [], "notes": [f"{c.binary} not found on PATH; install the app first"]}) # fmt: skip
|
|
82
|
+
continue
|
|
83
|
+
out = (
|
|
84
|
+
install(c, a.scope, argv, apply, a.force) if a.cmd == "install" else uninstall(c, a.scope, apply)
|
|
85
|
+
)
|
|
86
|
+
results.append(out.as_dict())
|
|
87
|
+
data = {"action": a.cmd, "applied": apply, "command": argv, "clients": results, "not_detected": missing}
|
|
88
|
+
_emit(a.json, data, _plan_text)
|
|
89
|
+
bad = any(r["status"] in ("error", "collision") for r in results)
|
|
90
|
+
return 1 if bad else 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _emit(as_json: bool, data: dict[str, Any], render) -> None:
|
|
94
|
+
print(json.dumps(data, indent=2) if as_json else render(data))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _plan_text(d: dict[str, Any]) -> str:
|
|
98
|
+
lines = [] if d["applied"] else ["Plan only. Nothing changed. Run again with --yes to apply."]
|
|
99
|
+
if d.get("command"):
|
|
100
|
+
lines.append("server command: " + " ".join(d["command"]))
|
|
101
|
+
for r in d["clients"]:
|
|
102
|
+
lines.append(f"[{r['client']}] {r['status']}")
|
|
103
|
+
lines += [f" {s}" for s in r["steps"]] + [f" note: {n}" for n in r["notes"]]
|
|
104
|
+
if d["not_detected"]:
|
|
105
|
+
lines.append("not detected (skipped): " + ", ".join(d["not_detected"]))
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _doctor_text(d: dict[str, Any]) -> str:
|
|
110
|
+
lines = []
|
|
111
|
+
for r in d["clients"]:
|
|
112
|
+
hs = r.get("handshake") or {}
|
|
113
|
+
lines.append(f"[{r['client']}] app: {'yes ' + str(r['version']) if r['present'] else 'not found'}")
|
|
114
|
+
lines.append(f" config: {r.get('config')} configured: {'yes' if r.get('configured') else 'no'}")
|
|
115
|
+
if r.get("command"):
|
|
116
|
+
lines.append(f" command: {' '.join(r['command'])} exists: {r.get('command_exists')}")
|
|
117
|
+
conn = f"yes ({hs.get('tools')} tools, {hs.get('protocol')})" if r.get("connects") else \
|
|
118
|
+
f"no ({hs.get('error') or r.get('error') or 'not configured'})" # fmt: skip
|
|
119
|
+
lines.append(f" connects: {conn} auth: {r.get('auth', 'not needed')}")
|
|
120
|
+
if d["not_detected"]:
|
|
121
|
+
lines.append("not detected: " + ", ".join(d["not_detected"]))
|
|
122
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Client-side health check: app present, entry configured, server answers over stdio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import selectors
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .clients import Client, InstallError
|
|
15
|
+
|
|
16
|
+
HANDSHAKE_TIMEOUT_S = 10.0
|
|
17
|
+
PROTOCOL = "2025-06-18"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_reply(proc: subprocess.Popen[bytes], want_id: int, deadline: float) -> dict[str, Any]:
|
|
21
|
+
assert proc.stdout is not None
|
|
22
|
+
sel = selectors.DefaultSelector()
|
|
23
|
+
sel.register(proc.stdout, selectors.EVENT_READ)
|
|
24
|
+
buf = b""
|
|
25
|
+
while True:
|
|
26
|
+
left = deadline - time.monotonic()
|
|
27
|
+
if left <= 0:
|
|
28
|
+
raise TimeoutError("no reply within the time limit")
|
|
29
|
+
if not sel.select(left):
|
|
30
|
+
continue
|
|
31
|
+
chunk = os.read(proc.stdout.fileno(), 65536)
|
|
32
|
+
if not chunk:
|
|
33
|
+
raise ConnectionError(f"server exited (code {proc.poll()})")
|
|
34
|
+
buf += chunk
|
|
35
|
+
while b"\n" in buf:
|
|
36
|
+
line, buf = buf.split(b"\n", 1)
|
|
37
|
+
try:
|
|
38
|
+
msg = json.loads(line)
|
|
39
|
+
except ValueError:
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(msg, dict) and msg.get("id") == want_id:
|
|
42
|
+
return msg
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def handshake(
|
|
46
|
+
argv: list[str],
|
|
47
|
+
env: dict[str, str] | None = None,
|
|
48
|
+
timeout: float = HANDSHAKE_TIMEOUT_S,
|
|
49
|
+
cwd: str | None = None,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""Spawn the server, send initialize + tools/list, return server name and tool count."""
|
|
52
|
+
deadline = time.monotonic() + timeout
|
|
53
|
+
full_env = {**os.environ, **(env or {})}
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
56
|
+
stderr=subprocess.DEVNULL, env=full_env, cwd=cwd) # fmt: skip
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
return {"ok": False, "error": f"cannot start: {exc}"}
|
|
59
|
+
assert proc.stdin is not None
|
|
60
|
+
try:
|
|
61
|
+
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
62
|
+
"protocolVersion": PROTOCOL, "capabilities": {},
|
|
63
|
+
"clientInfo": {"name": "subtask-doctor", "version": "1"}}} # fmt: skip
|
|
64
|
+
proc.stdin.write((json.dumps(init) + "\n").encode())
|
|
65
|
+
proc.stdin.flush()
|
|
66
|
+
r1 = _read_reply(proc, 1, deadline)
|
|
67
|
+
if "error" in r1:
|
|
68
|
+
return {"ok": False, "error": f"initialize: {r1['error'].get('message')}"}
|
|
69
|
+
notes = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
70
|
+
listing = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
|
|
71
|
+
proc.stdin.write((json.dumps(notes) + "\n" + json.dumps(listing) + "\n").encode())
|
|
72
|
+
proc.stdin.flush()
|
|
73
|
+
r2 = _read_reply(proc, 2, deadline)
|
|
74
|
+
tools = (r2.get("result") or {}).get("tools") or []
|
|
75
|
+
info = (r1.get("result") or {}).get("serverInfo") or {}
|
|
76
|
+
return {"ok": bool(tools), "server": info.get("name"), "version": info.get("version"),
|
|
77
|
+
"protocol": (r1.get("result") or {}).get("protocolVersion"), "tools": len(tools)} # fmt: skip
|
|
78
|
+
except (TimeoutError, ConnectionError, OSError) as exc:
|
|
79
|
+
return {"ok": False, "error": str(exc)}
|
|
80
|
+
finally:
|
|
81
|
+
try:
|
|
82
|
+
proc.stdin.close()
|
|
83
|
+
except OSError:
|
|
84
|
+
pass # the server already died: nothing to flush
|
|
85
|
+
try:
|
|
86
|
+
proc.wait(timeout=3)
|
|
87
|
+
except subprocess.TimeoutExpired:
|
|
88
|
+
proc.kill()
|
|
89
|
+
proc.wait()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def doctor(c: Client, scope: str) -> dict[str, Any]:
|
|
93
|
+
exe = c.exe()
|
|
94
|
+
report: dict[str, Any] = {"client": c.name, "scope": scope, "present": bool(exe), "binary": exe,
|
|
95
|
+
"version": c.version() if exe else None} # fmt: skip
|
|
96
|
+
try:
|
|
97
|
+
c.check_scope(scope)
|
|
98
|
+
path = c.config_path(scope)
|
|
99
|
+
entry = c.read_entry(scope)
|
|
100
|
+
except InstallError as exc:
|
|
101
|
+
return {**report, "configured": False, "connects": False, "error": str(exc)}
|
|
102
|
+
argv = Client.target(entry)
|
|
103
|
+
report |= {"config": str(path), "configured": bool(argv), "command": argv}
|
|
104
|
+
if not argv:
|
|
105
|
+
return {**report, "connects": False, "auth": "not needed"}
|
|
106
|
+
launcher = argv[0] if os.path.isabs(argv[0]) else shutil.which(argv[0])
|
|
107
|
+
report["command_exists"] = bool(launcher and Path(launcher).is_file() and os.access(launcher, os.X_OK))
|
|
108
|
+
env = (entry or {}).get("env") or {}
|
|
109
|
+
hs = handshake(argv, {str(k): str(v) for k, v in env.items()}) if report["command_exists"] else {
|
|
110
|
+
"ok": False, "error": "command not found or not executable"} # fmt: skip
|
|
111
|
+
# The subtask server needs no credentials to connect; engines read theirs only when a job runs.
|
|
112
|
+
return {**report, "connects": hs.pop("ok"), "handshake": hs, "auth": "not needed"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Safe file edits: backup, atomic write that keeps the mode, restore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sha256(data: bytes) -> str:
|
|
14
|
+
return hashlib.sha256(data).hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_bytes(path: Path) -> bytes | None:
|
|
18
|
+
try:
|
|
19
|
+
return path.read_bytes()
|
|
20
|
+
except FileNotFoundError:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def atomic_write(path: Path, data: bytes) -> None:
|
|
25
|
+
"""Write via a temp file in the same dir, then os.replace; keep the old mode."""
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
try:
|
|
28
|
+
mode: int | None = path.stat().st_mode & 0o7777
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
mode = None
|
|
31
|
+
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
32
|
+
try:
|
|
33
|
+
with os.fdopen(fd, "wb") as fh:
|
|
34
|
+
fh.write(data)
|
|
35
|
+
fh.flush()
|
|
36
|
+
os.fsync(fh.fileno())
|
|
37
|
+
if mode is not None:
|
|
38
|
+
os.chmod(tmp, mode)
|
|
39
|
+
os.replace(tmp, path)
|
|
40
|
+
except BaseException:
|
|
41
|
+
Path(tmp).unlink(missing_ok=True)
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Snapshot:
|
|
47
|
+
"""What a file held before we touched it; `backup` is the copy on disk, if any."""
|
|
48
|
+
|
|
49
|
+
path: Path
|
|
50
|
+
before: bytes | None
|
|
51
|
+
backup: Path | None
|
|
52
|
+
|
|
53
|
+
def restore(self) -> None:
|
|
54
|
+
if self.before is None:
|
|
55
|
+
self.path.unlink(missing_ok=True)
|
|
56
|
+
else:
|
|
57
|
+
atomic_write(self.path, self.before)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def snapshot(path: Path, keep_copy: bool = True) -> Snapshot:
|
|
61
|
+
"""Remember the file; with keep_copy leave a `<file>.subtask-bak-<timestamp>` copy next to it."""
|
|
62
|
+
before = read_bytes(path)
|
|
63
|
+
backup = None
|
|
64
|
+
if before is not None and keep_copy:
|
|
65
|
+
stamp = time.strftime("%Y%m%d-%H%M%S") + f"-{time.time_ns() % 1_000_000:06d}"
|
|
66
|
+
backup = path.with_name(f"{path.name}.subtask-bak-{stamp}")
|
|
67
|
+
atomic_write(backup, before)
|
|
68
|
+
os.chmod(backup, path.stat().st_mode & 0o7777)
|
|
69
|
+
return Snapshot(path, before, backup)
|