taskswarm-cli 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.
- taskswarm/__init__.py +67 -0
- taskswarm/adapters/__init__.py +11 -0
- taskswarm/adapters/claude_code_adapter.py +226 -0
- taskswarm/adapters/generic_adapter.py +43 -0
- taskswarm/adapters/types.py +23 -0
- taskswarm/cli.py +323 -0
- taskswarm/client/__init__.py +0 -0
- taskswarm/client/api_client.py +61 -0
- taskswarm/client/tasks_registry.py +119 -0
- taskswarm/notifications/__init__.py +3 -0
- taskswarm/notifications/dispatch.py +88 -0
- taskswarm/notifications/ntfy.py +61 -0
- taskswarm/notifications/os_notify.py +61 -0
- taskswarm/py.typed +0 -0
- taskswarm/schema/__init__.py +23 -0
- taskswarm/schema/events.py +221 -0
- taskswarm/server/__init__.py +33 -0
- taskswarm/server/auth.py +25 -0
- taskswarm/server/config.py +154 -0
- taskswarm/server/event_store.py +139 -0
- taskswarm/server/http_server.py +255 -0
- taskswarm/server/server.py +72 -0
- taskswarm/ui/index.html +241 -0
- taskswarm/util/__init__.py +0 -0
- taskswarm/util/sync_sleep.py +15 -0
- taskswarm_cli-0.1.0.dist-info/METADATA +217 -0
- taskswarm_cli-0.1.0.dist-info/RECORD +30 -0
- taskswarm_cli-0.1.0.dist-info/WHEEL +4 -0
- taskswarm_cli-0.1.0.dist-info/entry_points.txt +3 -0
- taskswarm_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
taskswarm/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public library surface. Most users will interact with TaskSwarm through the
|
|
3
|
+
CLI (`taskswarm`/`taskswarm-cli`); these exports exist for tooling that
|
|
4
|
+
wants to embed the event store, adapters, or server programmatically.
|
|
5
|
+
|
|
6
|
+
from taskswarm import start_server, EventStore
|
|
7
|
+
|
|
8
|
+
This is the Python port of the taskswarm-cli npm package
|
|
9
|
+
(https://www.npmjs.com/package/taskswarm-cli). Both distributions ship the
|
|
10
|
+
same event schema, event-server behavior, and notification logic; see
|
|
11
|
+
https://github.com/RudrenduPaul/taskswarm for the canonical documentation
|
|
12
|
+
and the original TypeScript source.
|
|
13
|
+
"""
|
|
14
|
+
from .adapters.claude_code_adapter import ClaudeCodeAdapter, install_claude_code_hooks
|
|
15
|
+
from .adapters.generic_adapter import GenericAdapter
|
|
16
|
+
from .adapters.types import AdapterValidationError, AgentAdapter
|
|
17
|
+
from .notifications.dispatch import NotifyOptions, notify, should_notify
|
|
18
|
+
from .schema.events import (
|
|
19
|
+
AGENT_STATUSES,
|
|
20
|
+
AGENT_TYPES,
|
|
21
|
+
CURRENT_SCHEMA_VERSION,
|
|
22
|
+
NOTIFY_ON_STATUSES,
|
|
23
|
+
AgentEvent,
|
|
24
|
+
EventValidationError,
|
|
25
|
+
to_agent_event,
|
|
26
|
+
)
|
|
27
|
+
from .server.config import (
|
|
28
|
+
TaskSwarmConfig,
|
|
29
|
+
generate_token,
|
|
30
|
+
get_taskswarm_home,
|
|
31
|
+
load_or_create_config,
|
|
32
|
+
rotate_token,
|
|
33
|
+
save_config,
|
|
34
|
+
)
|
|
35
|
+
from .server.event_store import EventStore, SessionState
|
|
36
|
+
from .server.server import RunningServer, start_server
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AGENT_STATUSES",
|
|
42
|
+
"AGENT_TYPES",
|
|
43
|
+
"CURRENT_SCHEMA_VERSION",
|
|
44
|
+
"NOTIFY_ON_STATUSES",
|
|
45
|
+
"AdapterValidationError",
|
|
46
|
+
"AgentAdapter",
|
|
47
|
+
"AgentEvent",
|
|
48
|
+
"ClaudeCodeAdapter",
|
|
49
|
+
"EventStore",
|
|
50
|
+
"EventValidationError",
|
|
51
|
+
"GenericAdapter",
|
|
52
|
+
"NotifyOptions",
|
|
53
|
+
"RunningServer",
|
|
54
|
+
"SessionState",
|
|
55
|
+
"TaskSwarmConfig",
|
|
56
|
+
"generate_token",
|
|
57
|
+
"get_taskswarm_home",
|
|
58
|
+
"install_claude_code_hooks",
|
|
59
|
+
"load_or_create_config",
|
|
60
|
+
"notify",
|
|
61
|
+
"rotate_token",
|
|
62
|
+
"save_config",
|
|
63
|
+
"should_notify",
|
|
64
|
+
"start_server",
|
|
65
|
+
"to_agent_event",
|
|
66
|
+
"__version__",
|
|
67
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .claude_code_adapter import ClaudeCodeAdapter, install_claude_code_hooks
|
|
2
|
+
from .generic_adapter import GenericAdapter
|
|
3
|
+
from .types import AdapterValidationError, AgentAdapter
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AdapterValidationError",
|
|
7
|
+
"AgentAdapter",
|
|
8
|
+
"ClaudeCodeAdapter",
|
|
9
|
+
"GenericAdapter",
|
|
10
|
+
"install_claude_code_hooks",
|
|
11
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Integration with Claude Code's real hooks system. Ported from
|
|
2
|
+
src/adapters/claude-code-adapter.ts.
|
|
3
|
+
|
|
4
|
+
VERIFIED against Claude Code's published hooks reference
|
|
5
|
+
(https://code.claude.com/docs/en/hooks.md and hooks-guide.md), same as the
|
|
6
|
+
TypeScript version:
|
|
7
|
+
- Hooks are configured under a top-level "hooks" key in settings.json,
|
|
8
|
+
scoped by file location: project (.claude/settings.json), local
|
|
9
|
+
(.claude/settings.local.json, gitignored), or user (~/.claude/settings.json).
|
|
10
|
+
- Each event maps to an array of { matcher, hooks: [{ type, command, timeout }] }
|
|
11
|
+
groups. matcher: "" (or omitted) matches every occurrence of the event.
|
|
12
|
+
- The "Stop" event fires when Claude Code finishes responding to a turn.
|
|
13
|
+
Its hook receives a JSON payload on stdin with at least: session_id,
|
|
14
|
+
transcript_path, cwd, hook_event_name.
|
|
15
|
+
- The "Notification" event fires when Claude Code surfaces a notification
|
|
16
|
+
to the user (permission prompts, idle waits, etc). Its hook receives
|
|
17
|
+
session_id, cwd, hook_event_name, and notification_type.
|
|
18
|
+
- Exit code 0 from a hook command is treated as "no objection"; this
|
|
19
|
+
adapter's relay always exits 0 (it only reports status, it never wants
|
|
20
|
+
to block Claude Code from stopping).
|
|
21
|
+
|
|
22
|
+
BEST-EFFORT / NOT independently verified against a live Claude Code install
|
|
23
|
+
in this codebase (same caveats as the TypeScript version):
|
|
24
|
+
- The exhaustive set of notification_type values beyond "permission_prompt"
|
|
25
|
+
and "idle_prompt" -- everything else falls through to a generic
|
|
26
|
+
"needs-review" so nothing is silently dropped.
|
|
27
|
+
- "Stop" firing semantics are per-turn, not per-task -- so mapping Stop
|
|
28
|
+
directly to TaskSwarm's 'done' status is a v0.1 approximation.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shlex
|
|
35
|
+
import sys
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
39
|
+
|
|
40
|
+
from .types import AdapterValidationError, AgentAdapter
|
|
41
|
+
|
|
42
|
+
HookInstallScope = str # "project" | "local" | "user"
|
|
43
|
+
|
|
44
|
+
# A stable substring every relay command contains, used to find and replace
|
|
45
|
+
# a previously installed relay hook (e.g. after an upgrade moves the CLI's
|
|
46
|
+
# install path) without leaving stale or duplicate entries behind.
|
|
47
|
+
_RELAY_MARKER = "hooks claude-code-relay"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
51
|
+
agent_type = "claude-code"
|
|
52
|
+
name = "Claude Code hooks adapter"
|
|
53
|
+
|
|
54
|
+
def to_event_input(self, raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
55
|
+
session_id = raw.get("session_id")
|
|
56
|
+
cwd = raw.get("cwd")
|
|
57
|
+
hook_event_name = raw.get("hook_event_name")
|
|
58
|
+
|
|
59
|
+
if not isinstance(session_id, str) or len(session_id) == 0:
|
|
60
|
+
raise AdapterValidationError("session_id is required in the hook payload")
|
|
61
|
+
if not isinstance(cwd, str) or len(cwd) == 0:
|
|
62
|
+
raise AdapterValidationError("cwd is required in the hook payload")
|
|
63
|
+
if not isinstance(hook_event_name, str):
|
|
64
|
+
raise AdapterValidationError("hook_event_name is required in the hook payload")
|
|
65
|
+
|
|
66
|
+
base = {"session_id": session_id, "repo": cwd, "agent_type": self.agent_type}
|
|
67
|
+
|
|
68
|
+
if hook_event_name == "Stop":
|
|
69
|
+
return {**base, "status": "done"}
|
|
70
|
+
|
|
71
|
+
if hook_event_name == "Notification":
|
|
72
|
+
notification_type = raw.get("notification_type")
|
|
73
|
+
if notification_type == "permission_prompt":
|
|
74
|
+
return {
|
|
75
|
+
**base,
|
|
76
|
+
"status": "needs-review",
|
|
77
|
+
"blocked_reason": "Claude Code is waiting for permission approval",
|
|
78
|
+
}
|
|
79
|
+
if notification_type == "idle_prompt":
|
|
80
|
+
return {
|
|
81
|
+
**base,
|
|
82
|
+
"status": "blocked",
|
|
83
|
+
"blocked_reason": "Claude Code session is idle, waiting for the next prompt",
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
**base,
|
|
87
|
+
"status": "needs-review",
|
|
88
|
+
"blocked_reason": (
|
|
89
|
+
f"Notification: {notification_type}"
|
|
90
|
+
if isinstance(notification_type, str)
|
|
91
|
+
else "Claude Code sent a notification"
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
raise AdapterValidationError(
|
|
96
|
+
f"unsupported hook_event_name: {hook_event_name} (this adapter handles Stop and Notification)"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_relay_command(cli_script_path: str) -> str:
|
|
101
|
+
"""Builds the exact command Claude Code should run for the Stop/
|
|
102
|
+
Notification hooks: the absolute path to the currently-installed
|
|
103
|
+
`taskswarm`/`taskswarm-cli` console script.
|
|
104
|
+
|
|
105
|
+
A Python console script (unlike the TS CLI's plain `.js` file) is
|
|
106
|
+
directly executable on its own -- it carries its own shebang line
|
|
107
|
+
pointing at the interpreter it was installed for -- so, unlike the
|
|
108
|
+
Node port, this does not need to separately quote an interpreter path
|
|
109
|
+
ahead of the script path.
|
|
110
|
+
|
|
111
|
+
Deliberately NOT invoked via `python -m taskswarm.cli` or a bare
|
|
112
|
+
`taskswarm` looked up fresh from PATH on every hook fire: this resolves
|
|
113
|
+
once, at install time, to the exact script already on disk, and that
|
|
114
|
+
resolved path is what gets written into settings.json. The rationale is
|
|
115
|
+
the same supply-chain one documented in the original TypeScript
|
|
116
|
+
adapter: `Stop` fires on every Claude Code turn, so a hook command that
|
|
117
|
+
re-resolves against a floating reference (a PATH lookup that could
|
|
118
|
+
change, or a package registry) on every single fire is a needless
|
|
119
|
+
repeated trust decision. Invoking the exact binary already resolved at
|
|
120
|
+
install time means the hook only ever runs code that was already
|
|
121
|
+
trusted at that point.
|
|
122
|
+
"""
|
|
123
|
+
return shlex.quote(cli_script_path) + " hooks claude-code-relay"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _settings_path_for_scope(scope: HookInstallScope, project_dir: str, home_dir: str) -> str:
|
|
127
|
+
if scope == "project":
|
|
128
|
+
return str(Path(project_dir) / ".claude" / "settings.json")
|
|
129
|
+
if scope == "local":
|
|
130
|
+
return str(Path(project_dir) / ".claude" / "settings.local.json")
|
|
131
|
+
if scope == "user":
|
|
132
|
+
return str(Path(home_dir) / ".claude" / "settings.json")
|
|
133
|
+
raise AdapterValidationError(f"unknown hook install scope: {scope}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_settings(path: str) -> Dict[str, Any]:
|
|
137
|
+
if not os.path.exists(path):
|
|
138
|
+
return {}
|
|
139
|
+
raw = Path(path).read_text(encoding="utf-8").strip()
|
|
140
|
+
if len(raw) == 0:
|
|
141
|
+
return {}
|
|
142
|
+
try:
|
|
143
|
+
return json.loads(raw)
|
|
144
|
+
except json.JSONDecodeError as error:
|
|
145
|
+
raise AdapterValidationError(
|
|
146
|
+
f"{path} is not valid JSON, so TaskSwarm can't safely merge hooks into it. "
|
|
147
|
+
f"Fix or remove the file, then re-run hooks install. ({error})"
|
|
148
|
+
) from error
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _find_relay_hook_command(groups: Optional[List[Dict[str, Any]]]) -> Optional[str]:
|
|
152
|
+
if not groups:
|
|
153
|
+
return None
|
|
154
|
+
for group in groups:
|
|
155
|
+
for hook in group.get("hooks", []):
|
|
156
|
+
if _RELAY_MARKER in hook.get("command", ""):
|
|
157
|
+
return hook["command"]
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _add_relay_hook(settings: Dict[str, Any], event: str, relay_command: str) -> bool:
|
|
162
|
+
"""Installs or repoints the relay hook for one event. Idempotent when
|
|
163
|
+
the resolved command hasn't changed; self-healing (replaces the stale
|
|
164
|
+
entry rather than adding a duplicate) when it has, e.g. after the CLI's
|
|
165
|
+
install path moved between an upgrade."""
|
|
166
|
+
settings.setdefault("hooks", {})
|
|
167
|
+
existing = settings["hooks"].get(event)
|
|
168
|
+
current_command = _find_relay_hook_command(existing)
|
|
169
|
+
if current_command == relay_command:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
groups_without_stale_relay = []
|
|
173
|
+
for group in existing or []:
|
|
174
|
+
hooks = [hook for hook in group.get("hooks", []) if _RELAY_MARKER not in hook.get("command", "")]
|
|
175
|
+
if hooks:
|
|
176
|
+
groups_without_stale_relay.append({**group, "hooks": hooks})
|
|
177
|
+
|
|
178
|
+
new_group = {"matcher": "", "hooks": [{"type": "command", "command": relay_command, "timeout": 10}]}
|
|
179
|
+
settings["hooks"][event] = [*groups_without_stale_relay, new_group]
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class InstallHooksResult:
|
|
185
|
+
settings_path: str
|
|
186
|
+
changed: bool
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
189
|
+
return {"settingsPath": self.settings_path, "changed": self.changed}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def install_claude_code_hooks(
|
|
193
|
+
scope: HookInstallScope,
|
|
194
|
+
project_dir: str,
|
|
195
|
+
home_dir: str,
|
|
196
|
+
cli_script_path: Optional[str] = None,
|
|
197
|
+
) -> InstallHooksResult:
|
|
198
|
+
"""Writes (merging with any existing content) Stop and Notification hook
|
|
199
|
+
entries into the appropriate Claude Code settings.json, pointing at
|
|
200
|
+
TaskSwarm's relay command -- resolved to the exact console script
|
|
201
|
+
already installed on this machine, never a floating PATH lookup.
|
|
202
|
+
Idempotent: running it again when the hooks are already installed and
|
|
203
|
+
pointing at the same resolved path is a no-op (changed=False); repoints
|
|
204
|
+
(without duplicating) if the resolved path has changed since the last
|
|
205
|
+
install."""
|
|
206
|
+
resolved_script_path = cli_script_path or os.path.abspath(sys.argv[0])
|
|
207
|
+
if not resolved_script_path:
|
|
208
|
+
raise AdapterValidationError(
|
|
209
|
+
"could not resolve the running CLI script path to install a hook against"
|
|
210
|
+
)
|
|
211
|
+
relay_command = build_relay_command(resolved_script_path)
|
|
212
|
+
|
|
213
|
+
settings_path = _settings_path_for_scope(scope, project_dir, home_dir)
|
|
214
|
+
settings = _read_settings(settings_path)
|
|
215
|
+
|
|
216
|
+
stop_changed = _add_relay_hook(settings, "Stop", relay_command)
|
|
217
|
+
notification_changed = _add_relay_hook(settings, "Notification", relay_command)
|
|
218
|
+
changed = stop_changed or notification_changed
|
|
219
|
+
|
|
220
|
+
if changed:
|
|
221
|
+
directory = os.path.dirname(settings_path)
|
|
222
|
+
if directory and not os.path.exists(directory):
|
|
223
|
+
os.makedirs(directory, exist_ok=True)
|
|
224
|
+
Path(settings_path).write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
return InstallHooksResult(settings_path=settings_path, changed=changed)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The wrapper-script fallback any agent (Codex, Cursor, or anything else
|
|
2
|
+
without a dedicated adapter) can use. This is the real, always-works
|
|
3
|
+
integration path in v0.1: a wrapper script around any coding-agent CLI calls
|
|
4
|
+
`taskswarm agent report-status --task <id> --repo <path> --state <status>`
|
|
5
|
+
at the points it cares about, which flows through this adapter. Ported from
|
|
6
|
+
src/adapters/generic-adapter.ts."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
from ..schema.events import AGENT_STATUSES, AGENT_TYPES
|
|
12
|
+
from .types import AdapterValidationError, AgentAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GenericAdapter(AgentAdapter):
|
|
16
|
+
agent_type = "generic"
|
|
17
|
+
name = "Generic wrapper-script adapter"
|
|
18
|
+
|
|
19
|
+
def to_event_input(self, raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
20
|
+
session_id = raw.get("session_id")
|
|
21
|
+
repo = raw.get("repo")
|
|
22
|
+
status = raw.get("status")
|
|
23
|
+
blocked_reason = raw.get("blocked_reason")
|
|
24
|
+
agent_type_raw = raw.get("agent_type", "generic")
|
|
25
|
+
|
|
26
|
+
if not isinstance(session_id, str) or len(session_id) == 0:
|
|
27
|
+
raise AdapterValidationError("session_id is required")
|
|
28
|
+
if not isinstance(repo, str) or len(repo) == 0:
|
|
29
|
+
raise AdapterValidationError("repo is required")
|
|
30
|
+
if not isinstance(status, str) or status not in AGENT_STATUSES:
|
|
31
|
+
raise AdapterValidationError(f"status must be one of: {', '.join(AGENT_STATUSES)}")
|
|
32
|
+
if not isinstance(agent_type_raw, str) or agent_type_raw not in AGENT_TYPES:
|
|
33
|
+
raise AdapterValidationError(f"agent_type must be one of: {', '.join(AGENT_TYPES)}")
|
|
34
|
+
|
|
35
|
+
input_data: Dict[str, Any] = {
|
|
36
|
+
"session_id": session_id,
|
|
37
|
+
"repo": repo,
|
|
38
|
+
"agent_type": agent_type_raw,
|
|
39
|
+
"status": status,
|
|
40
|
+
}
|
|
41
|
+
if isinstance(blocked_reason, str) and len(blocked_reason) > 0:
|
|
42
|
+
input_data["blocked_reason"] = blocked_reason
|
|
43
|
+
return input_data
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Plugin point every agent integration implements. An adapter's only job is
|
|
2
|
+
to normalize whatever raw, agent-specific payload it receives (a CLI flag
|
|
3
|
+
bag, a hook's stdin JSON, ...) into a schema-valid event-input dict. Ship
|
|
4
|
+
new integrations (Codex, Cursor, ...) by adding a new adapter here rather
|
|
5
|
+
than branching inside the server or CLI. Ported from src/adapters/types.ts."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdapterValidationError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentAdapter(ABC):
|
|
17
|
+
agent_type: str
|
|
18
|
+
name: str
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def to_event_input(self, raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
22
|
+
"""Normalizes adapter-specific raw input into a schema-valid event input."""
|
|
23
|
+
raise NotImplementedError
|