subtask-mcp 0.2.3__tar.gz → 0.2.4__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.4}/PKG-INFO +2 -2
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/README.md +1 -1
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/__init__.py +1 -1
- subtask_mcp-0.2.4/subtask/install/claude_plugin.py +134 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/commands.py +41 -4
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask_mcp.egg-info/PKG-INFO +2 -2
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask_mcp.egg-info/SOURCES.txt +1 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_install.py +53 -1
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/LICENSE +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/pyproject.toml +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/setup.cfg +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/admission.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/capacity.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/cli.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/config.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/core.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/doctor.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/__init__.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/base.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/claude_api.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/codex.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/demo.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/dsh.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/kimi.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/opencode.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/engines/registry.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/events.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/evidence.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/fastserver.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/gc.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/__init__.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/clients.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/doctor.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/fileops.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/installer.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/ownership.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/install/tomledit.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/jobs.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/ledger.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/narrate.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/policy.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/procid.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/progress.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/redact.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/roi.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/router.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/runners.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/schemacheck.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/server.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/skill/SKILL.md +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/telemetry.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/tools.json +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask/worker.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask_mcp.egg-info/dependency_links.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask_mcp.egg-info/entry_points.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask_mcp.egg-info/requires.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/subtask_mcp.egg-info/top_level.txt +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_concurrency.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_engines.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_falsify.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_independent_client.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_protocol.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_routing_results.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_schema.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/tests/test_security.py +0 -0
- {subtask_mcp-0.2.3 → subtask_mcp-0.2.4}/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.4
|
|
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.4" # 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.4" # 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')}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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.4" # 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
|
|
@@ -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"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|