claude-task-master 0.1.1__py3-none-any.whl → 0.1.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.
- claude_task_master/__init__.py +1 -1
- claude_task_master/api/__init__.py +98 -0
- claude_task_master/api/models.py +553 -0
- claude_task_master/api/routes.py +1135 -0
- claude_task_master/api/routes_config.py +160 -0
- claude_task_master/api/routes_control.py +278 -0
- claude_task_master/api/routes_webhooks.py +980 -0
- claude_task_master/api/server.py +551 -0
- claude_task_master/auth/__init__.py +89 -0
- claude_task_master/auth/middleware.py +448 -0
- claude_task_master/auth/password.py +332 -0
- claude_task_master/bin/claudetm +1 -1
- claude_task_master/cli.py +4 -0
- claude_task_master/cli_commands/__init__.py +2 -0
- claude_task_master/cli_commands/ci_helpers.py +114 -0
- claude_task_master/cli_commands/control.py +191 -0
- claude_task_master/cli_commands/fix_pr.py +260 -0
- claude_task_master/cli_commands/fix_session.py +174 -0
- claude_task_master/cli_commands/workflow.py +51 -3
- claude_task_master/core/__init__.py +13 -0
- claude_task_master/core/agent_message.py +27 -5
- claude_task_master/core/control.py +466 -0
- claude_task_master/core/orchestrator.py +316 -4
- claude_task_master/core/pr_context.py +7 -2
- claude_task_master/core/prompts_working.py +32 -12
- claude_task_master/core/state.py +84 -2
- claude_task_master/core/state_exceptions.py +9 -6
- claude_task_master/core/workflow_stages.py +160 -21
- claude_task_master/github/client_pr.py +43 -1
- claude_task_master/mcp/auth.py +153 -0
- claude_task_master/mcp/server.py +268 -10
- claude_task_master/mcp/tools.py +281 -0
- claude_task_master/server.py +489 -0
- claude_task_master/webhooks/__init__.py +73 -0
- claude_task_master/webhooks/client.py +703 -0
- claude_task_master/webhooks/config.py +565 -0
- claude_task_master/webhooks/events.py +639 -0
- {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/METADATA +144 -6
- {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/RECORD +42 -21
- {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/entry_points.txt +2 -0
- {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/WHEEL +0 -0
- {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Password hashing and verification utilities for Claude Task Master.
|
|
2
|
+
|
|
3
|
+
This module provides secure password hashing using bcrypt via passlib,
|
|
4
|
+
along with environment variable configuration and password comparison.
|
|
5
|
+
|
|
6
|
+
Environment Variables:
|
|
7
|
+
CLAUDETM_PASSWORD: The password for authenticating API/MCP requests.
|
|
8
|
+
CLAUDETM_PASSWORD_HASH: Pre-hashed password (bcrypt) for production use.
|
|
9
|
+
|
|
10
|
+
Security Notes:
|
|
11
|
+
- Always use CLAUDETM_PASSWORD_HASH in production to avoid plaintext passwords in env
|
|
12
|
+
- Passwords are compared using constant-time comparison to prevent timing attacks
|
|
13
|
+
- bcrypt automatically handles salting and multiple rounds
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Environment variable names
|
|
29
|
+
ENV_PASSWORD = "CLAUDETM_PASSWORD"
|
|
30
|
+
ENV_PASSWORD_HASH = "CLAUDETM_PASSWORD_HASH"
|
|
31
|
+
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# Exceptions
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthenticationError(Exception):
|
|
38
|
+
"""Base exception for authentication errors."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PasswordNotConfiguredError(AuthenticationError):
|
|
44
|
+
"""Raised when password authentication is required but not configured."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str | None = None) -> None:
|
|
47
|
+
"""Initialize the exception.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
message: Optional custom message. Defaults to standard message.
|
|
51
|
+
"""
|
|
52
|
+
default_message = (
|
|
53
|
+
f"Password not configured. Set {ENV_PASSWORD} or {ENV_PASSWORD_HASH} "
|
|
54
|
+
"environment variable."
|
|
55
|
+
)
|
|
56
|
+
super().__init__(message or default_message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InvalidPasswordError(AuthenticationError):
|
|
60
|
+
"""Raised when password verification fails."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str = "Invalid password") -> None:
|
|
63
|
+
"""Initialize the exception.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
message: Custom error message.
|
|
67
|
+
"""
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# Password Hashing
|
|
73
|
+
# =============================================================================
|
|
74
|
+
|
|
75
|
+
# Try to import passlib for bcrypt hashing
|
|
76
|
+
try:
|
|
77
|
+
from passlib.context import CryptContext
|
|
78
|
+
|
|
79
|
+
# Create a bcrypt context for password hashing
|
|
80
|
+
# Using bcrypt with default rounds (12) for security
|
|
81
|
+
_pwd_context: CryptContext | None = CryptContext(
|
|
82
|
+
schemes=["bcrypt"],
|
|
83
|
+
deprecated="auto",
|
|
84
|
+
bcrypt__rounds=12,
|
|
85
|
+
)
|
|
86
|
+
PASSLIB_AVAILABLE = True
|
|
87
|
+
except ImportError:
|
|
88
|
+
_pwd_context = None
|
|
89
|
+
PASSLIB_AVAILABLE = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ensure_passlib() -> None:
|
|
93
|
+
"""Ensure passlib is available, raise ImportError if not.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ImportError: If passlib[bcrypt] is not installed.
|
|
97
|
+
"""
|
|
98
|
+
if not PASSLIB_AVAILABLE:
|
|
99
|
+
raise ImportError(
|
|
100
|
+
"passlib[bcrypt] not installed. Install with: "
|
|
101
|
+
"pip install 'claude-task-master[api]' or pip install 'passlib[bcrypt]'"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _truncate_password_for_bcrypt(password: str) -> str:
|
|
106
|
+
"""Truncate password to bcrypt's 72-byte limit.
|
|
107
|
+
|
|
108
|
+
bcrypt has a fundamental 72-byte password limit. Passwords longer than
|
|
109
|
+
72 bytes (when UTF-8 encoded) must be truncated. This is done at a
|
|
110
|
+
character boundary to avoid breaking multi-byte characters.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
password: The password to potentially truncate.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The password truncated to at most 72 bytes when UTF-8 encoded.
|
|
117
|
+
"""
|
|
118
|
+
# Encode to bytes to check actual byte length
|
|
119
|
+
encoded = password.encode("utf-8")
|
|
120
|
+
if len(encoded) <= 72:
|
|
121
|
+
return password
|
|
122
|
+
|
|
123
|
+
# Truncate at byte boundary, then decode
|
|
124
|
+
# We need to be careful not to break a multi-byte character
|
|
125
|
+
truncated = encoded[:72]
|
|
126
|
+
# Find the last complete character by decoding with error handling
|
|
127
|
+
# If truncation breaks a multi-byte character, we need to go back
|
|
128
|
+
while True:
|
|
129
|
+
try:
|
|
130
|
+
return truncated.decode("utf-8")
|
|
131
|
+
except UnicodeDecodeError:
|
|
132
|
+
truncated = truncated[:-1]
|
|
133
|
+
if not truncated:
|
|
134
|
+
# This shouldn't happen with valid UTF-8 input
|
|
135
|
+
return password[:72]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def hash_password(password: str) -> str:
|
|
139
|
+
"""Hash a password using bcrypt.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
password: The plaintext password to hash.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The bcrypt hash of the password.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ImportError: If passlib[bcrypt] is not installed.
|
|
149
|
+
ValueError: If password is empty.
|
|
150
|
+
|
|
151
|
+
Note:
|
|
152
|
+
bcrypt has a 72-byte password limit. Passwords longer than 72 bytes
|
|
153
|
+
(when UTF-8 encoded) will be truncated. This is a bcrypt limitation,
|
|
154
|
+
not a security concern for most use cases.
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
>>> hashed = hash_password("my_secret_password")
|
|
158
|
+
>>> hashed.startswith("$2b$") # bcrypt hash format
|
|
159
|
+
True
|
|
160
|
+
"""
|
|
161
|
+
_ensure_passlib()
|
|
162
|
+
|
|
163
|
+
if not password:
|
|
164
|
+
raise ValueError("Password cannot be empty")
|
|
165
|
+
|
|
166
|
+
# Truncate to bcrypt's 72-byte limit
|
|
167
|
+
password = _truncate_password_for_bcrypt(password)
|
|
168
|
+
|
|
169
|
+
assert _pwd_context is not None # ensured by _ensure_passlib
|
|
170
|
+
result: str = _pwd_context.hash(password)
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
175
|
+
"""Verify a password against a bcrypt hash.
|
|
176
|
+
|
|
177
|
+
This function uses constant-time comparison to prevent timing attacks.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
plain_password: The plaintext password to verify.
|
|
181
|
+
hashed_password: The bcrypt hash to verify against.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if the password matches, False otherwise.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
ImportError: If passlib[bcrypt] is not installed.
|
|
188
|
+
|
|
189
|
+
Note:
|
|
190
|
+
bcrypt has a 72-byte password limit. Passwords are truncated to
|
|
191
|
+
match the behavior during hashing.
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
>>> hashed = hash_password("my_password")
|
|
195
|
+
>>> verify_password("my_password", hashed)
|
|
196
|
+
True
|
|
197
|
+
>>> verify_password("wrong_password", hashed)
|
|
198
|
+
False
|
|
199
|
+
"""
|
|
200
|
+
_ensure_passlib()
|
|
201
|
+
|
|
202
|
+
if not plain_password or not hashed_password:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# Truncate to bcrypt's 72-byte limit (must match hash_password behavior)
|
|
207
|
+
plain_password = _truncate_password_for_bcrypt(plain_password)
|
|
208
|
+
|
|
209
|
+
assert _pwd_context is not None # ensured by _ensure_passlib
|
|
210
|
+
result: bool = _pwd_context.verify(plain_password, hashed_password)
|
|
211
|
+
return result
|
|
212
|
+
except Exception:
|
|
213
|
+
# Any exception during verification means the password is invalid
|
|
214
|
+
# This includes malformed hashes
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def verify_password_plaintext(plain_password: str, expected_password: str) -> bool:
|
|
219
|
+
"""Verify a password against a plaintext expected password.
|
|
220
|
+
|
|
221
|
+
Uses constant-time comparison to prevent timing attacks.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
plain_password: The password to verify.
|
|
225
|
+
expected_password: The expected plaintext password.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True if passwords match, False otherwise.
|
|
229
|
+
"""
|
|
230
|
+
if not plain_password or not expected_password:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
return secrets.compare_digest(plain_password, expected_password)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# =============================================================================
|
|
237
|
+
# Environment Configuration
|
|
238
|
+
# =============================================================================
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_password_from_env() -> str | None:
|
|
242
|
+
"""Get the configured password from environment variables.
|
|
243
|
+
|
|
244
|
+
Checks for password configuration in order of preference:
|
|
245
|
+
1. CLAUDETM_PASSWORD_HASH - pre-hashed bcrypt password (recommended for production)
|
|
246
|
+
2. CLAUDETM_PASSWORD - plaintext password (for development/testing)
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The configured password (plaintext) or password hash, or None if not configured.
|
|
250
|
+
|
|
251
|
+
Note:
|
|
252
|
+
When CLAUDETM_PASSWORD is set, it returns the plaintext password.
|
|
253
|
+
When CLAUDETM_PASSWORD_HASH is set, it returns the hash.
|
|
254
|
+
The caller should use is_password_hash() to determine which type was returned.
|
|
255
|
+
"""
|
|
256
|
+
# First check for pre-hashed password (production)
|
|
257
|
+
password_hash = os.getenv(ENV_PASSWORD_HASH)
|
|
258
|
+
if password_hash:
|
|
259
|
+
return password_hash
|
|
260
|
+
|
|
261
|
+
# Fall back to plaintext password (development)
|
|
262
|
+
password = os.getenv(ENV_PASSWORD)
|
|
263
|
+
if password:
|
|
264
|
+
return password
|
|
265
|
+
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def is_password_hash(value: str) -> bool:
|
|
270
|
+
"""Check if a value appears to be a bcrypt hash.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
value: The value to check.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if the value looks like a bcrypt hash, False otherwise.
|
|
277
|
+
"""
|
|
278
|
+
if not value:
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
# bcrypt hashes start with $2a$, $2b$, or $2y$ followed by cost factor
|
|
282
|
+
return value.startswith(("$2a$", "$2b$", "$2y$"))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def require_password_from_env() -> str:
|
|
286
|
+
"""Get the configured password, raising an error if not configured.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
The configured password or password hash.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
PasswordNotConfiguredError: If no password is configured.
|
|
293
|
+
"""
|
|
294
|
+
password = get_password_from_env()
|
|
295
|
+
if password is None:
|
|
296
|
+
raise PasswordNotConfiguredError()
|
|
297
|
+
return password
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def authenticate(provided_password: str) -> bool:
|
|
301
|
+
"""Authenticate a provided password against the configured password.
|
|
302
|
+
|
|
303
|
+
This function handles both plaintext and hashed password configurations:
|
|
304
|
+
- If CLAUDETM_PASSWORD_HASH is set, verifies against the hash
|
|
305
|
+
- If CLAUDETM_PASSWORD is set, compares plaintext (constant-time)
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
provided_password: The password to authenticate.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
True if authentication succeeds, False otherwise.
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
PasswordNotConfiguredError: If no password is configured.
|
|
315
|
+
"""
|
|
316
|
+
configured = require_password_from_env()
|
|
317
|
+
|
|
318
|
+
if is_password_hash(configured):
|
|
319
|
+
# Verify against bcrypt hash
|
|
320
|
+
return verify_password(provided_password, configured)
|
|
321
|
+
else:
|
|
322
|
+
# Compare plaintext (constant-time)
|
|
323
|
+
return verify_password_plaintext(provided_password, configured)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_auth_enabled() -> bool:
|
|
327
|
+
"""Check if password authentication is enabled.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
True if a password is configured, False otherwise.
|
|
331
|
+
"""
|
|
332
|
+
return get_password_from_env() is not None
|
claude_task_master/bin/claudetm
CHANGED
|
@@ -33,7 +33,7 @@ set -euo pipefail
|
|
|
33
33
|
|
|
34
34
|
# Script version - synchronized with Python package version
|
|
35
35
|
# This should be kept in sync using scripts/sync_version.py
|
|
36
|
-
SCRIPT_VERSION="0.1.
|
|
36
|
+
SCRIPT_VERSION="0.1.2"
|
|
37
37
|
|
|
38
38
|
# Configuration file location
|
|
39
39
|
CONFIG_DIR=".claude-task-master"
|
claude_task_master/cli.py
CHANGED
|
@@ -7,6 +7,8 @@ from rich.console import Console
|
|
|
7
7
|
|
|
8
8
|
from . import __version__
|
|
9
9
|
from .cli_commands.config import register_config_commands
|
|
10
|
+
from .cli_commands.control import register_control_commands
|
|
11
|
+
from .cli_commands.fix_pr import register_fix_pr_command
|
|
10
12
|
from .cli_commands.github import register_github_commands
|
|
11
13
|
from .cli_commands.info import register_info_commands
|
|
12
14
|
from .cli_commands.workflow import register_workflow_commands
|
|
@@ -65,6 +67,8 @@ register_workflow_commands(app) # start, resume
|
|
|
65
67
|
register_info_commands(app) # status, plan, logs, context, progress
|
|
66
68
|
register_github_commands(app) # ci-status, ci-logs, pr-comments, pr-status
|
|
67
69
|
register_config_commands(app) # config init, config show, config path
|
|
70
|
+
register_control_commands(app) # pause, stop, config-update
|
|
71
|
+
register_fix_pr_command(app) # fix-pr
|
|
68
72
|
|
|
69
73
|
|
|
70
74
|
@app.command()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""CLI command modules for Claude Task Master."""
|
|
2
2
|
|
|
3
3
|
from .config import register_config_commands
|
|
4
|
+
from .fix_pr import register_fix_pr_command
|
|
4
5
|
from .github import register_github_commands
|
|
5
6
|
from .info import register_info_commands
|
|
6
7
|
from .workflow import register_workflow_commands
|
|
@@ -10,4 +11,5 @@ __all__ = [
|
|
|
10
11
|
"register_info_commands",
|
|
11
12
|
"register_github_commands",
|
|
12
13
|
"register_config_commands",
|
|
14
|
+
"register_fix_pr_command",
|
|
13
15
|
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""CI helper functions for fix-pr command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ..core import console
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..github import GitHubClient, PRStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Polling intervals
|
|
15
|
+
CI_POLL_INTERVAL = 10 # seconds between CI checks (matches orchestrator)
|
|
16
|
+
CI_START_WAIT = 30 # seconds to wait for CI to start after push
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_check_pending(check: dict[str, Any]) -> bool:
|
|
20
|
+
"""Check if a CI check or status is still pending.
|
|
21
|
+
|
|
22
|
+
Handles both CheckRun (GitHub Actions) and StatusContext (external services like CodeRabbit).
|
|
23
|
+
|
|
24
|
+
CheckRun states:
|
|
25
|
+
- status: QUEUED, IN_PROGRESS, COMPLETED
|
|
26
|
+
- conclusion: success, failure, etc. (only set when COMPLETED)
|
|
27
|
+
|
|
28
|
+
StatusContext states:
|
|
29
|
+
- state: PENDING, EXPECTED, SUCCESS, FAILURE, ERROR
|
|
30
|
+
- Maps to both status and conclusion in our normalized format
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
check: Normalized check detail dictionary.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if the check is still pending, False if complete.
|
|
37
|
+
"""
|
|
38
|
+
status = (check.get("status") or "").upper()
|
|
39
|
+
conclusion = check.get("conclusion")
|
|
40
|
+
|
|
41
|
+
# StatusContext with PENDING or EXPECTED state is still waiting
|
|
42
|
+
# (These get mapped to both status and conclusion)
|
|
43
|
+
if status in ("PENDING", "EXPECTED"):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
# CheckRun is pending if not completed or has no conclusion yet
|
|
47
|
+
if status not in ("COMPLETED",) and conclusion is None:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def wait_for_ci_complete(github_client: GitHubClient, pr_number: int) -> PRStatus:
|
|
54
|
+
"""Wait for all CI checks to complete.
|
|
55
|
+
|
|
56
|
+
Fetches required checks from branch protection and waits for all of them
|
|
57
|
+
to report, even if they haven't started yet (like CodeRabbit).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
github_client: GitHub client for API calls.
|
|
61
|
+
pr_number: PR number to check.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Final PRStatus after all checks complete.
|
|
65
|
+
"""
|
|
66
|
+
console.info(f"Waiting for CI checks on PR #{pr_number}...")
|
|
67
|
+
|
|
68
|
+
# Get required checks from branch protection (once at start)
|
|
69
|
+
status = github_client.get_pr_status(pr_number)
|
|
70
|
+
required_checks = set(github_client.get_required_status_checks(status.base_branch))
|
|
71
|
+
|
|
72
|
+
while True:
|
|
73
|
+
status = github_client.get_pr_status(pr_number)
|
|
74
|
+
|
|
75
|
+
# Get reported check names
|
|
76
|
+
reported = {check.get("name", "") for check in status.check_details}
|
|
77
|
+
|
|
78
|
+
# Find required checks that haven't reported yet
|
|
79
|
+
missing = required_checks - reported
|
|
80
|
+
|
|
81
|
+
# Count pending checks (in progress or not yet complete)
|
|
82
|
+
pending = [
|
|
83
|
+
check.get("name", "unknown")
|
|
84
|
+
for check in status.check_details
|
|
85
|
+
if is_check_pending(check)
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# All pending = running checks + missing required checks
|
|
89
|
+
all_waiting = list(missing) + pending
|
|
90
|
+
|
|
91
|
+
if not all_waiting:
|
|
92
|
+
# All checks reported - verify no conflicts
|
|
93
|
+
if status.mergeable == "CONFLICTING":
|
|
94
|
+
console.warning("⚠ PR has merge conflicts")
|
|
95
|
+
return status
|
|
96
|
+
|
|
97
|
+
# Build status summary
|
|
98
|
+
passed = status.checks_passed
|
|
99
|
+
failed = status.checks_failed
|
|
100
|
+
status_parts = []
|
|
101
|
+
if passed:
|
|
102
|
+
status_parts.append(f"{passed} passed")
|
|
103
|
+
if failed:
|
|
104
|
+
status_parts.append(f"{failed} failed")
|
|
105
|
+
status_summary = f" ({', '.join(status_parts)})" if status_parts else ""
|
|
106
|
+
|
|
107
|
+
# Show what we're waiting for
|
|
108
|
+
console.info(
|
|
109
|
+
f"⏳ Waiting for {len(all_waiting)} check(s): "
|
|
110
|
+
f"{', '.join(all_waiting[:3])}{'...' if len(all_waiting) > 3 else ''}"
|
|
111
|
+
f"{status_summary}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
time.sleep(CI_POLL_INTERVAL)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Control commands for Claude Task Master - pause, stop, resume, config."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from ..core.control import ControlManager
|
|
9
|
+
from ..core.state import StateManager
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def pause(
|
|
15
|
+
reason: Annotated[
|
|
16
|
+
str | None,
|
|
17
|
+
typer.Option("--reason", "-r", help="Reason for pausing the task"),
|
|
18
|
+
] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Pause a running task.
|
|
21
|
+
|
|
22
|
+
Pauses the current task, which can be resumed later using 'resume'.
|
|
23
|
+
Task must be in planning or working status to be paused.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
claudetm pause
|
|
27
|
+
claudetm pause --reason "Taking a break"
|
|
28
|
+
"""
|
|
29
|
+
state_manager = StateManager()
|
|
30
|
+
|
|
31
|
+
if not state_manager.exists():
|
|
32
|
+
console.print("[yellow]No active task found.[/yellow]")
|
|
33
|
+
console.print("Use 'start' to begin a new task.")
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
control = ControlManager(state_manager)
|
|
38
|
+
|
|
39
|
+
# Check if task can be paused
|
|
40
|
+
if not control.can_pause():
|
|
41
|
+
state = state_manager.load_state()
|
|
42
|
+
console.print(f"[red]Cannot pause task in '{state.status}' status.[/red]")
|
|
43
|
+
console.print("[dim]Task must be in 'planning' or 'working' status to pause.[/dim]")
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
|
|
46
|
+
# Pause the task
|
|
47
|
+
result = control.pause(reason)
|
|
48
|
+
|
|
49
|
+
console.print(f"[green]✓ {result.message}[/green]")
|
|
50
|
+
|
|
51
|
+
if result.details and result.details.get("reason"):
|
|
52
|
+
console.print(f"[dim]Reason: {result.details['reason']}[/dim]")
|
|
53
|
+
console.print("[dim]Use 'resume' to continue the task.[/dim]")
|
|
54
|
+
|
|
55
|
+
raise typer.Exit(0)
|
|
56
|
+
|
|
57
|
+
except typer.Exit:
|
|
58
|
+
raise
|
|
59
|
+
except Exception as e:
|
|
60
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
61
|
+
raise typer.Exit(1) from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stop(
|
|
65
|
+
reason: Annotated[
|
|
66
|
+
str | None,
|
|
67
|
+
typer.Option("--reason", "-r", help="Reason for stopping the task"),
|
|
68
|
+
] = None,
|
|
69
|
+
cleanup: Annotated[
|
|
70
|
+
bool,
|
|
71
|
+
typer.Option("--cleanup", "-c", help="Cleanup state files after stopping"),
|
|
72
|
+
] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Stop a running task.
|
|
75
|
+
|
|
76
|
+
Stops the current task. The task enters 'stopped' status and can be
|
|
77
|
+
resumed if needed. Use --cleanup to remove state files entirely.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
claudetm stop
|
|
81
|
+
claudetm stop --reason "Task completed"
|
|
82
|
+
claudetm stop --cleanup
|
|
83
|
+
"""
|
|
84
|
+
state_manager = StateManager()
|
|
85
|
+
|
|
86
|
+
if not state_manager.exists():
|
|
87
|
+
console.print("[yellow]No active task found.[/yellow]")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
control = ControlManager(state_manager)
|
|
92
|
+
|
|
93
|
+
# Check if task can be stopped
|
|
94
|
+
if not control.can_stop():
|
|
95
|
+
state = state_manager.load_state()
|
|
96
|
+
console.print(f"[red]Cannot stop task in '{state.status}' status.[/red]")
|
|
97
|
+
console.print(
|
|
98
|
+
"[dim]Task must be in 'planning', 'working', 'blocked', or 'paused' status to stop.[/dim]"
|
|
99
|
+
)
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
# Stop the task
|
|
103
|
+
result = control.stop(reason, cleanup)
|
|
104
|
+
|
|
105
|
+
console.print(f"[green]✓ {result.message}[/green]")
|
|
106
|
+
|
|
107
|
+
if result.details:
|
|
108
|
+
if result.details.get("reason"):
|
|
109
|
+
console.print(f"[dim]Reason: {result.details['reason']}[/dim]")
|
|
110
|
+
if result.details.get("cleanup"):
|
|
111
|
+
console.print("[dim]State files cleaned up.[/dim]")
|
|
112
|
+
|
|
113
|
+
raise typer.Exit(0)
|
|
114
|
+
|
|
115
|
+
except typer.Exit:
|
|
116
|
+
raise
|
|
117
|
+
except Exception as e:
|
|
118
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
119
|
+
raise typer.Exit(1) from None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def config_update(
|
|
123
|
+
auto_merge: bool | None = typer.Option(
|
|
124
|
+
None, "--auto-merge/--no-auto-merge", help="Set auto-merge option"
|
|
125
|
+
),
|
|
126
|
+
max_sessions: int | None = typer.Option(None, "--max-sessions", "-n", help="Set max sessions"),
|
|
127
|
+
pause_on_pr: bool | None = typer.Option(
|
|
128
|
+
None, "--pause-on-pr/--no-pause-on-pr", help="Set pause on PR"
|
|
129
|
+
),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Update task configuration at runtime.
|
|
132
|
+
|
|
133
|
+
Updates configuration options for the current task. Only specified
|
|
134
|
+
options are updated; others retain their current values.
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
claudetm config-update --auto-merge
|
|
138
|
+
claudetm config-update --no-auto-merge --max-sessions 10
|
|
139
|
+
claudetm config-update --pause-on-pr
|
|
140
|
+
"""
|
|
141
|
+
state_manager = StateManager()
|
|
142
|
+
|
|
143
|
+
if not state_manager.exists():
|
|
144
|
+
console.print("[yellow]No active task found.[/yellow]")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
|
|
147
|
+
# Check if any options were provided
|
|
148
|
+
if all(v is None for v in [auto_merge, max_sessions, pause_on_pr]):
|
|
149
|
+
console.print("[yellow]No configuration options specified.[/yellow]")
|
|
150
|
+
console.print("Use --help to see available options.")
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
control = ControlManager(state_manager)
|
|
155
|
+
|
|
156
|
+
# Build kwargs with only provided options
|
|
157
|
+
kwargs: dict[str, Any] = {}
|
|
158
|
+
if auto_merge is not None:
|
|
159
|
+
kwargs["auto_merge"] = auto_merge
|
|
160
|
+
if max_sessions is not None:
|
|
161
|
+
kwargs["max_sessions"] = max_sessions
|
|
162
|
+
if pause_on_pr is not None:
|
|
163
|
+
kwargs["pause_on_pr"] = pause_on_pr
|
|
164
|
+
|
|
165
|
+
# Update configuration
|
|
166
|
+
result = control.update_config(**kwargs)
|
|
167
|
+
|
|
168
|
+
console.print(f"[green]✓ {result.message}[/green]")
|
|
169
|
+
|
|
170
|
+
# Show current configuration
|
|
171
|
+
if result.details and result.details.get("current"):
|
|
172
|
+
current = result.details["current"]
|
|
173
|
+
console.print("\n[cyan]Current Configuration:[/cyan]")
|
|
174
|
+
console.print(f" Auto-merge: {current.get('auto_merge')}")
|
|
175
|
+
console.print(f" Max sessions: {current.get('max_sessions') or 'unlimited'}")
|
|
176
|
+
console.print(f" Pause on PR: {current.get('pause_on_pr')}")
|
|
177
|
+
|
|
178
|
+
raise typer.Exit(0)
|
|
179
|
+
|
|
180
|
+
except typer.Exit:
|
|
181
|
+
raise
|
|
182
|
+
except Exception as e:
|
|
183
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
184
|
+
raise typer.Exit(1) from None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def register_control_commands(app: typer.Typer) -> None:
|
|
188
|
+
"""Register control commands with the Typer app."""
|
|
189
|
+
app.command()(pause)
|
|
190
|
+
app.command()(stop)
|
|
191
|
+
app.command()(config_update)
|