subtask-mcp 0.2.3__tar.gz → 0.2.6__tar.gz
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_mcp-0.2.3 → subtask_mcp-0.2.6}/PKG-INFO +2 -2
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/README.md +1 -1
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/__init__.py +1 -1
- subtask_mcp-0.2.6/subtask/install/claude_plugin.py +134 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/commands.py +41 -4
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/worker.py +7 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/PKG-INFO +2 -2
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/SOURCES.txt +1 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_concurrency.py +24 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_install.py +53 -1
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_routing_results.py +11 -4
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/LICENSE +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/pyproject.toml +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/setup.cfg +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/admission.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/capacity.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/cli.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/config.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/core.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/doctor.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/__init__.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/base.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/claude_api.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/codex.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/demo.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/dsh.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/kimi.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/opencode.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/registry.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/events.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/evidence.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/fastserver.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/gc.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/__init__.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/clients.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/doctor.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/fileops.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/installer.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/ownership.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/tomledit.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/jobs.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/ledger.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/narrate.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/policy.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/procid.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/progress.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/redact.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/roi.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/router.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/runners.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/schemacheck.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/server.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/skill/SKILL.md +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/telemetry.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/tools.json +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/dependency_links.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/entry_points.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/requires.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/top_level.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_engines.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_falsify.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_independent_client.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_protocol.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_schema.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_security.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_subtask.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/tedin7/subtask
|
|
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
|
70
70
|
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
71
71
|
|
|
72
72
|
```sh
|
|
73
|
-
uv tool install "git+https://github.com/tedin7/subtask@v0.2.
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.6" # or: uv tool install /path/to/checkout
|
|
74
74
|
subtask install --client all # prints the plan for every detected client
|
|
75
75
|
subtask install --client all --yes # applies it: MCP server + skill
|
|
76
76
|
subtask doctor --client all # checks app, config entry and a real handshake
|
|
@@ -39,7 +39,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
|
39
39
|
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
40
40
|
|
|
41
41
|
```sh
|
|
42
|
-
uv tool install "git+https://github.com/tedin7/subtask@v0.2.
|
|
42
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.6" # or: uv tool install /path/to/checkout
|
|
43
43
|
subtask install --client all # prints the plan for every detected client
|
|
44
44
|
subtask install --client all --yes # applies it: MCP server + skill
|
|
45
45
|
subtask doctor --client all # checks app, config entry and a real handshake
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Claude Code through its plugin: one server, one skill, one hook, from one place.
|
|
2
|
+
|
|
3
|
+
The plugin (marketplace `subtask` in the tedin7/subtask repository) carries the
|
|
4
|
+
MCP server, the skill and the timing hook. Installing it this way replaces the
|
|
5
|
+
older route (a user-scope `claude mcp add` entry plus a copied skill), so the
|
|
6
|
+
server is never registered twice. Everything goes through `claude plugin ...`
|
|
7
|
+
commands; the only file subtask touches is its own skill copy, and only when
|
|
8
|
+
the ownership record says it is ours.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import ownership
|
|
17
|
+
from .clients import Claude, InstallError, run_native
|
|
18
|
+
from .fileops import read_bytes, sha256
|
|
19
|
+
from .installer import Outcome
|
|
20
|
+
|
|
21
|
+
MARKETPLACE = "subtask"
|
|
22
|
+
SOURCE = "tedin7/subtask"
|
|
23
|
+
PLUGIN = f"subtask@{MARKETPLACE}"
|
|
24
|
+
SERVER = "plugin:subtask:subtask"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _out(argv: list[str], timeout: float = 60) -> str:
|
|
28
|
+
try:
|
|
29
|
+
done = subprocess.run(
|
|
30
|
+
argv, capture_output=True, text=True, timeout=timeout, check=False, stdin=subprocess.DEVNULL
|
|
31
|
+
)
|
|
32
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
33
|
+
raise InstallError(f"{' '.join(argv[:3])}: {exc}") from exc
|
|
34
|
+
return done.stdout + done.stderr
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def state(c: Claude) -> dict:
|
|
38
|
+
exe = c.exe() or "claude"
|
|
39
|
+
markets = _out([exe, "plugin", "marketplace", "list"])
|
|
40
|
+
plugins = _out([exe, "plugin", "list"])
|
|
41
|
+
return {
|
|
42
|
+
"marketplace": f"❯ {MARKETPLACE}" in markets
|
|
43
|
+
or f" {MARKETPLACE}\n" in markets
|
|
44
|
+
or MARKETPLACE in markets.split(),
|
|
45
|
+
"plugin": PLUGIN in plugins,
|
|
46
|
+
"legacy_entry": c.read_entry("user"),
|
|
47
|
+
"skill": c.skill_path("user"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ours(path: Path, client: str = "claude") -> bool:
|
|
52
|
+
cur = read_bytes(path)
|
|
53
|
+
rec = ownership.get(ownership.key(client, "user", "skill", path))
|
|
54
|
+
return cur is not None and bool(rec) and rec.get("sha") == sha256(cur)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install(c: Claude, apply: bool, force: bool, source: str = SOURCE) -> Outcome:
|
|
58
|
+
out = Outcome(client="claude", scope="user")
|
|
59
|
+
exe = c.exe() or "claude"
|
|
60
|
+
st = state(c)
|
|
61
|
+
steps: list[list[str]] = []
|
|
62
|
+
if st["marketplace"]:
|
|
63
|
+
steps.append([exe, "plugin", "marketplace", "update", MARKETPLACE])
|
|
64
|
+
else:
|
|
65
|
+
steps.append([exe, "plugin", "marketplace", "add", source])
|
|
66
|
+
steps.append([exe, "plugin", "update" if st["plugin"] else "install", PLUGIN])
|
|
67
|
+
legacy = st["legacy_entry"]
|
|
68
|
+
if legacy:
|
|
69
|
+
target = " ".join(Claude.target(legacy) or [])
|
|
70
|
+
if "subtask-mcp" in target or force:
|
|
71
|
+
steps.append([exe, "mcp", "remove", "-s", "user", "subtask"]) # the plugin now provides it
|
|
72
|
+
else:
|
|
73
|
+
out.status = "collision"
|
|
74
|
+
out.notes.append(
|
|
75
|
+
f"a user-scope 'subtask' entry runs {target!r}; use --force to replace it with the plugin"
|
|
76
|
+
)
|
|
77
|
+
return out
|
|
78
|
+
skill: Path = st["skill"]
|
|
79
|
+
remove_skill = skill.is_file() and (_ours(skill) or force)
|
|
80
|
+
if skill.is_file() and not remove_skill:
|
|
81
|
+
out.notes.append(f"{skill} is not ours: kept (the plugin also ships the skill)")
|
|
82
|
+
out.steps = [f"run: {' '.join(s)}" for s in steps] + (
|
|
83
|
+
[f"remove: {skill} (the plugin ships it)"] if remove_skill else []
|
|
84
|
+
)
|
|
85
|
+
if not apply:
|
|
86
|
+
return out
|
|
87
|
+
try:
|
|
88
|
+
for argv in steps:
|
|
89
|
+
run_native(argv)
|
|
90
|
+
if remove_skill:
|
|
91
|
+
skill.unlink()
|
|
92
|
+
try:
|
|
93
|
+
skill.parent.rmdir()
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
ownership.drop(ownership.key("claude", "user", "skill", skill))
|
|
97
|
+
except InstallError as exc:
|
|
98
|
+
out.status = "error"
|
|
99
|
+
out.notes.append(str(exc))
|
|
100
|
+
return out
|
|
101
|
+
out.status = "done"
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def uninstall(c: Claude, apply: bool) -> Outcome:
|
|
106
|
+
out = Outcome(client="claude", scope="user")
|
|
107
|
+
exe = c.exe() or "claude"
|
|
108
|
+
if PLUGIN not in _out([exe, "plugin", "list"]):
|
|
109
|
+
out.status = "unchanged"
|
|
110
|
+
out.notes.append("plugin not installed")
|
|
111
|
+
return out
|
|
112
|
+
out.steps = [f"run: {exe} plugin uninstall {PLUGIN}"]
|
|
113
|
+
if apply:
|
|
114
|
+
try:
|
|
115
|
+
run_native([exe, "plugin", "uninstall", PLUGIN])
|
|
116
|
+
except InstallError as exc:
|
|
117
|
+
out.status = "error"
|
|
118
|
+
out.notes.append(str(exc))
|
|
119
|
+
return out
|
|
120
|
+
out.status = "done"
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def doctor(c: Claude) -> dict:
|
|
125
|
+
exe = c.exe()
|
|
126
|
+
report = {"client": "claude", "scope": "user", "mode": "plugin", "present": bool(exe),
|
|
127
|
+
"version": c.version() if exe else None} # fmt: skip
|
|
128
|
+
if not exe:
|
|
129
|
+
return {**report, "connects": False, "error": "claude not on PATH"}
|
|
130
|
+
listing = _out([exe, "mcp", "list"], timeout=120)
|
|
131
|
+
line = next((x for x in listing.splitlines() if x.startswith(SERVER)), "")
|
|
132
|
+
duplicate = any(x.startswith("subtask:") for x in listing.splitlines())
|
|
133
|
+
return {**report, "configured": bool(line), "connects": "Connected" in line and "failed" not in line.lower(),
|
|
134
|
+
"line": line.strip()[:160], "duplicate_user_entry": duplicate} # fmt: skip
|
|
@@ -8,6 +8,7 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from . import claude_plugin
|
|
11
12
|
from .clients import CLIENTS, Client, InstallError
|
|
12
13
|
from .doctor import doctor
|
|
13
14
|
from .installer import install, uninstall
|
|
@@ -37,6 +38,9 @@ def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
|
37
38
|
i.add_argument(
|
|
38
39
|
"--force", action="store_true", help="replace an existing 'subtask' entry that is not ours"
|
|
39
40
|
)
|
|
41
|
+
for p in (i, u, d):
|
|
42
|
+
p.add_argument("--no-plugin", action="store_true",
|
|
43
|
+
help="claude: register the server with `claude mcp add` instead of the plugin") # fmt: skip
|
|
40
44
|
i.add_argument("--command", help="server command to register (default: subtask-mcp next to this python)")
|
|
41
45
|
|
|
42
46
|
|
|
@@ -63,8 +67,14 @@ def _selected(name: str) -> tuple[list[Client], list[str]]:
|
|
|
63
67
|
|
|
64
68
|
def run(a: argparse.Namespace) -> int:
|
|
65
69
|
clients, missing = _selected(a.client)
|
|
70
|
+
use_plugin = a.scope == "user" and not getattr(a, "no_plugin", False)
|
|
66
71
|
if a.cmd == "doctor":
|
|
67
|
-
reports = [
|
|
72
|
+
reports = [
|
|
73
|
+
claude_plugin.doctor(c)
|
|
74
|
+
if use_plugin and c.name == "claude" and _plugin_mode(c)
|
|
75
|
+
else doctor(c, a.scope)
|
|
76
|
+
for c in clients
|
|
77
|
+
]
|
|
68
78
|
ok = all(r.get("connects") for r in reports) and bool(reports)
|
|
69
79
|
_emit(a.json, {"action": "doctor", "clients": reports, "not_detected": missing}, _doctor_text)
|
|
70
80
|
return 0 if ok else 1
|
|
@@ -80,9 +90,18 @@ def run(a: argparse.Namespace) -> int:
|
|
|
80
90
|
results.append({"client": c.name, "scope": a.scope, "status": "skipped",
|
|
81
91
|
"steps": [], "notes": [f"{c.binary} not found on PATH; install the app first"]}) # fmt: skip
|
|
82
92
|
continue
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
if c.name == "claude" and use_plugin:
|
|
94
|
+
out = (
|
|
95
|
+
claude_plugin.install(c, apply, a.force)
|
|
96
|
+
if a.cmd == "install"
|
|
97
|
+
else claude_plugin.uninstall(c, apply)
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
out = (
|
|
101
|
+
install(c, a.scope, argv, apply, a.force)
|
|
102
|
+
if a.cmd == "install"
|
|
103
|
+
else uninstall(c, a.scope, apply)
|
|
104
|
+
)
|
|
86
105
|
results.append(out.as_dict())
|
|
87
106
|
data = {"action": a.cmd, "applied": apply, "command": argv, "clients": results, "not_detected": missing}
|
|
88
107
|
_emit(a.json, data, _plan_text)
|
|
@@ -90,6 +109,13 @@ def run(a: argparse.Namespace) -> int:
|
|
|
90
109
|
return 1 if bad else 0
|
|
91
110
|
|
|
92
111
|
|
|
112
|
+
def _plugin_mode(c) -> bool:
|
|
113
|
+
try:
|
|
114
|
+
return claude_plugin.state(c)["plugin"]
|
|
115
|
+
except InstallError:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
93
119
|
def _emit(as_json: bool, data: dict[str, Any], render) -> None:
|
|
94
120
|
print(json.dumps(data, indent=2) if as_json else render(data))
|
|
95
121
|
|
|
@@ -111,6 +137,17 @@ def _doctor_text(d: dict[str, Any]) -> str:
|
|
|
111
137
|
for r in d["clients"]:
|
|
112
138
|
hs = r.get("handshake") or {}
|
|
113
139
|
lines.append(f"[{r['client']}] app: {'yes ' + str(r['version']) if r['present'] else 'not found'}")
|
|
140
|
+
if r.get("mode") == "plugin":
|
|
141
|
+
lines.append(f" plugin: {r.get('line') or 'not listed by claude mcp list'}")
|
|
142
|
+
lines.append(
|
|
143
|
+
f" connects: {'yes' if r.get('connects') else 'no'}"
|
|
144
|
+
+ (
|
|
145
|
+
" WARNING: also a separate user-scope 'subtask' entry"
|
|
146
|
+
if r.get("duplicate_user_entry")
|
|
147
|
+
else ""
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
114
151
|
lines.append(f" config: {r.get('config')} configured: {'yes' if r.get('configured') else 'no'}")
|
|
115
152
|
if r.get("command"):
|
|
116
153
|
lines.append(f" command: {' '.join(r['command'])} exists: {r.get('command_exists')}")
|
|
@@ -147,6 +147,13 @@ def main() -> int:
|
|
|
147
147
|
error=None,
|
|
148
148
|
)
|
|
149
149
|
emit("start", f"{spec['engine']} attempt {attempt}", state="running")
|
|
150
|
+
if adapter.capabilities.paid and not rid:
|
|
151
|
+
# Started by an older server that left the booking to the worker (v0 jobs):
|
|
152
|
+
# reserve here, so the spend still counts against the monthly cap.
|
|
153
|
+
rid = f"{job_id}#w{attempt}-{os.getpid()}"
|
|
154
|
+
if ledger.reserve(rid, job_id, float(spec["max_usd"])) is None:
|
|
155
|
+
rid = None
|
|
156
|
+
raise RuntimeError(f"monthly cap {config.MONTHLY_CAP_USD} $ reached")
|
|
150
157
|
if not Path(spec["cwd"]).is_dir():
|
|
151
158
|
raise RuntimeError(f"working folder is gone: {spec['cwd']} (cleaned up?)")
|
|
152
159
|
level = (spec.get("policy") or {}).get("effective")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/tedin7/subtask
|
|
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
|
70
70
|
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
71
71
|
|
|
72
72
|
```sh
|
|
73
|
-
uv tool install "git+https://github.com/tedin7/subtask@v0.2.
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.6" # or: uv tool install /path/to/checkout
|
|
74
74
|
subtask install --client all # prints the plan for every detected client
|
|
75
75
|
subtask install --client all --yes # applies it: MCP server + skill
|
|
76
76
|
subtask doctor --client all # checks app, config entry and a real handshake
|
|
@@ -273,3 +273,27 @@ def test_missing_active_index_does_not_forget_running_jobs(home):
|
|
|
273
273
|
assert sorted(admission.active()) == sorted([first, second])
|
|
274
274
|
core.stop(first)
|
|
275
275
|
core.stop(second)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_job_started_by_an_old_server_still_books_its_spend(home, monkeypatch):
|
|
279
|
+
"""v0 spec/status (no attempts, no reservation): the worker reserves and settles itself."""
|
|
280
|
+
import signal
|
|
281
|
+
|
|
282
|
+
from subtask import jobs, ledger, worker
|
|
283
|
+
|
|
284
|
+
job = "st-v0v0v0v0v0"
|
|
285
|
+
folder = jobs.job_dir(job)
|
|
286
|
+
folder.mkdir(parents=True)
|
|
287
|
+
jobs.write_json(folder / "spec.json", {"task": "x", "engine": "fake", "cwd": str(home), "max_usd": 2.0,
|
|
288
|
+
"max_turns": 5, "effort": "low", "created": time.time()}) # fmt: skip
|
|
289
|
+
jobs.write_json(folder / "status.json", {"state": "queued"})
|
|
290
|
+
monkeypatch.setenv("SUBTASK_FAKE_SCRIPT", 'echo \'{"say":"ok"}\'')
|
|
291
|
+
monkeypatch.setattr(sys, "argv", ["worker", job])
|
|
292
|
+
old = signal.getsignal(signal.SIGTERM)
|
|
293
|
+
try:
|
|
294
|
+
worker.main()
|
|
295
|
+
finally:
|
|
296
|
+
signal.signal(signal.SIGTERM, old)
|
|
297
|
+
assert jobs.status(job)["state"] == "done"
|
|
298
|
+
assert ledger.committed() == pytest.approx(0.25)
|
|
299
|
+
assert ledger.open_for(job) == []
|
|
@@ -46,7 +46,8 @@ def env():
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def cli(e: dict, *args: str, cwd: Path | None = None) -> tuple[int, dict]:
|
|
49
|
-
|
|
49
|
+
# These tests cover the `claude mcp add` route; plugin mode has its own tests below.
|
|
50
|
+
proc = subprocess.run([sys.executable, "-m", "subtask.cli", *args, "--json", "--no-plugin"], env=e,
|
|
50
51
|
capture_output=True, text=True, timeout=120, cwd=cwd or e["HOME"], check=False) # fmt: skip
|
|
51
52
|
return proc.returncode, json.loads(proc.stdout)
|
|
52
53
|
|
|
@@ -364,3 +365,54 @@ def test_opencode_jsonc_is_read_and_never_rewritten_with_comments(tmp_path, monk
|
|
|
364
365
|
assert (
|
|
365
366
|
clients.strip_jsonc('{"a": "http://x//y", /* c */ "b": [1,],}') == '{"a": "http://x//y", "b": [1]}'
|
|
366
367
|
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_claude_plugin_mode_replaces_the_legacy_entry_and_skill(tmp_path, monkeypatch):
|
|
371
|
+
from subtask.install import claude_plugin, clients, ownership
|
|
372
|
+
from subtask.install.fileops import sha256
|
|
373
|
+
|
|
374
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
375
|
+
c = clients.CLIENTS["claude"]()
|
|
376
|
+
(tmp_path / ".claude.json").write_text(
|
|
377
|
+
json.dumps({"mcpServers": {"subtask": {"command": "/x/bin/subtask-mcp", "args": []}}})
|
|
378
|
+
)
|
|
379
|
+
skill = c.skill_path("user")
|
|
380
|
+
skill.parent.mkdir(parents=True)
|
|
381
|
+
skill.write_bytes(b"old skill")
|
|
382
|
+
ownership.put(ownership.key("claude", "user", "skill", skill), {"sha": sha256(b"old skill")})
|
|
383
|
+
ran: list[list[str]] = []
|
|
384
|
+
listings = {
|
|
385
|
+
"marketplace": "Configured marketplaces:\n\n ❯ claude-code-lsps\n",
|
|
386
|
+
"plugin": "Installed plugins:\n",
|
|
387
|
+
}
|
|
388
|
+
monkeypatch.setattr(
|
|
389
|
+
claude_plugin,
|
|
390
|
+
"_out",
|
|
391
|
+
lambda argv, timeout=60: listings["marketplace" if "marketplace" in argv else "plugin"],
|
|
392
|
+
)
|
|
393
|
+
monkeypatch.setattr(claude_plugin, "run_native", lambda argv: ran.append(argv))
|
|
394
|
+
monkeypatch.setattr(c, "exe", lambda: "claude")
|
|
395
|
+
plan = claude_plugin.install(c, apply=False, force=False)
|
|
396
|
+
assert ran == [] and skill.is_file() # plan only
|
|
397
|
+
out = claude_plugin.install(c, apply=True, force=False)
|
|
398
|
+
assert out.status == "done", out.notes
|
|
399
|
+
assert ran == [
|
|
400
|
+
["claude", "plugin", "marketplace", "add", "tedin7/subtask"],
|
|
401
|
+
["claude", "plugin", "install", "subtask@subtask"],
|
|
402
|
+
["claude", "mcp", "remove", "-s", "user", "subtask"],
|
|
403
|
+
]
|
|
404
|
+
assert not skill.exists() and plan.steps[-1].startswith("remove:")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_claude_plugin_mode_refuses_a_foreign_entry_without_force(tmp_path, monkeypatch):
|
|
408
|
+
from subtask.install import claude_plugin, clients
|
|
409
|
+
|
|
410
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
411
|
+
c = clients.CLIENTS["claude"]()
|
|
412
|
+
(tmp_path / ".claude.json").write_text(
|
|
413
|
+
json.dumps({"mcpServers": {"subtask": {"command": "/other/tool"}}})
|
|
414
|
+
)
|
|
415
|
+
monkeypatch.setattr(claude_plugin, "_out", lambda argv, timeout=60: "")
|
|
416
|
+
monkeypatch.setattr(claude_plugin, "run_native", lambda argv: (_ for _ in ()).throw(AssertionError(argv)))
|
|
417
|
+
monkeypatch.setattr(c, "exe", lambda: "claude")
|
|
418
|
+
assert claude_plugin.install(c, apply=True, force=False).status == "collision"
|
|
@@ -196,16 +196,23 @@ def test_doctor_reports_without_spending(home):
|
|
|
196
196
|
|
|
197
197
|
|
|
198
198
|
def test_active_index_makes_counting_independent_of_history(home):
|
|
199
|
+
"""Counting must not grow with finished jobs: compare with an empty history, not a fixed time."""
|
|
199
200
|
from subtask import admission, config, jobs
|
|
200
201
|
|
|
202
|
+
def per_call() -> float:
|
|
203
|
+
t = time.perf_counter()
|
|
204
|
+
for _ in range(20):
|
|
205
|
+
admission.count()
|
|
206
|
+
return (time.perf_counter() - t) / 20
|
|
207
|
+
|
|
208
|
+
admission.count()
|
|
209
|
+
empty = per_call()
|
|
201
210
|
for i in range(300):
|
|
202
211
|
folder = config.JOBS / f"st-old{i:05d}"
|
|
203
212
|
folder.mkdir(parents=True)
|
|
204
213
|
jobs.write_json(folder / "status.json", {"state": "done"})
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
admission.count()
|
|
208
|
-
assert (time.perf_counter() - t) / 20 < 0.02 # does not scan 300 finished jobs
|
|
214
|
+
full = per_call()
|
|
215
|
+
assert full < empty * 3 + 0.005, (empty, full) # O(history) would be ~300x
|
|
209
216
|
|
|
210
217
|
|
|
211
218
|
def test_narrator_context_includes_observed_activity_not_only_intentions(home):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|