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 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