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.
Files changed (42) hide show
  1. claude_task_master/__init__.py +1 -1
  2. claude_task_master/api/__init__.py +98 -0
  3. claude_task_master/api/models.py +553 -0
  4. claude_task_master/api/routes.py +1135 -0
  5. claude_task_master/api/routes_config.py +160 -0
  6. claude_task_master/api/routes_control.py +278 -0
  7. claude_task_master/api/routes_webhooks.py +980 -0
  8. claude_task_master/api/server.py +551 -0
  9. claude_task_master/auth/__init__.py +89 -0
  10. claude_task_master/auth/middleware.py +448 -0
  11. claude_task_master/auth/password.py +332 -0
  12. claude_task_master/bin/claudetm +1 -1
  13. claude_task_master/cli.py +4 -0
  14. claude_task_master/cli_commands/__init__.py +2 -0
  15. claude_task_master/cli_commands/ci_helpers.py +114 -0
  16. claude_task_master/cli_commands/control.py +191 -0
  17. claude_task_master/cli_commands/fix_pr.py +260 -0
  18. claude_task_master/cli_commands/fix_session.py +174 -0
  19. claude_task_master/cli_commands/workflow.py +51 -3
  20. claude_task_master/core/__init__.py +13 -0
  21. claude_task_master/core/agent_message.py +27 -5
  22. claude_task_master/core/control.py +466 -0
  23. claude_task_master/core/orchestrator.py +316 -4
  24. claude_task_master/core/pr_context.py +7 -2
  25. claude_task_master/core/prompts_working.py +32 -12
  26. claude_task_master/core/state.py +84 -2
  27. claude_task_master/core/state_exceptions.py +9 -6
  28. claude_task_master/core/workflow_stages.py +160 -21
  29. claude_task_master/github/client_pr.py +43 -1
  30. claude_task_master/mcp/auth.py +153 -0
  31. claude_task_master/mcp/server.py +268 -10
  32. claude_task_master/mcp/tools.py +281 -0
  33. claude_task_master/server.py +489 -0
  34. claude_task_master/webhooks/__init__.py +73 -0
  35. claude_task_master/webhooks/client.py +703 -0
  36. claude_task_master/webhooks/config.py +565 -0
  37. claude_task_master/webhooks/events.py +639 -0
  38. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/METADATA +144 -6
  39. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/RECORD +42 -21
  40. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/entry_points.txt +2 -0
  41. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/WHEEL +0 -0
  42. {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
@@ -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.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)