claude-team-mcp 0.7.0__py3-none-any.whl → 0.8.2__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_team/events.py CHANGED
@@ -4,11 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  from dataclasses import asdict, dataclass
6
6
  from datetime import datetime, timedelta, timezone
7
+ import logging
7
8
  import json
8
9
  import os
9
10
  from pathlib import Path
10
11
  from typing import Literal
11
12
 
13
+ from claude_team_mcp.config import ConfigError, EventsConfig, load_config
14
+
12
15
  try:
13
16
  import fcntl
14
17
  except ImportError: # pragma: no cover - platform-specific
@@ -19,6 +22,8 @@ try:
19
22
  except ImportError: # pragma: no cover - platform-specific
20
23
  msvcrt = None
21
24
 
25
+ logger = logging.getLogger("claude-team-mcp")
26
+
22
27
 
23
28
  EventType = Literal[
24
29
  "snapshot",
@@ -40,8 +45,20 @@ def _int_env(name: str, default: int) -> int:
40
45
  return default
41
46
 
42
47
 
43
- DEFAULT_ROTATION_MAX_SIZE_MB = _int_env("CLAUDE_TEAM_EVENTS_MAX_SIZE_MB", 1)
44
- DEFAULT_ROTATION_RECENT_HOURS = _int_env("CLAUDE_TEAM_EVENTS_RECENT_HOURS", 24)
48
+ def _load_rotation_config() -> EventsConfig:
49
+ # Resolve rotation defaults from config, applying env overrides.
50
+ try:
51
+ config = load_config()
52
+ events_config = config.events
53
+ except ConfigError as exc:
54
+ logger.warning(
55
+ "Invalid config file; using default event rotation config: %s", exc
56
+ )
57
+ events_config = EventsConfig()
58
+ return EventsConfig(
59
+ max_size_mb=_int_env("CLAUDE_TEAM_EVENTS_MAX_SIZE_MB", events_config.max_size_mb),
60
+ recent_hours=_int_env("CLAUDE_TEAM_EVENTS_RECENT_HOURS", events_config.recent_hours),
61
+ )
45
62
 
46
63
 
47
64
  @dataclass
@@ -89,6 +106,7 @@ def append_events(events: list[WorkerEvent]) -> None:
89
106
  payloads = [json.dumps(_event_to_dict(event), ensure_ascii=False) for event in events]
90
107
  block = "\n".join(payloads) + "\n"
91
108
  event_ts = _latest_event_timestamp(events)
109
+ rotation_config = _load_rotation_config()
92
110
 
93
111
  with path.open("r+", encoding="utf-8") as handle:
94
112
  _lock_file(handle)
@@ -97,8 +115,8 @@ def append_events(events: list[WorkerEvent]) -> None:
97
115
  handle,
98
116
  path,
99
117
  current_ts=event_ts,
100
- max_size_mb=DEFAULT_ROTATION_MAX_SIZE_MB,
101
- recent_hours=DEFAULT_ROTATION_RECENT_HOURS,
118
+ max_size_mb=rotation_config.max_size_mb,
119
+ recent_hours=rotation_config.recent_hours,
102
120
  )
103
121
  # Hold the lock across the entire write and flush cycle.
104
122
  handle.seek(0, os.SEEK_END)
@@ -169,8 +187,8 @@ def get_latest_snapshot() -> dict | None:
169
187
 
170
188
 
171
189
  def rotate_events_log(
172
- max_size_mb: int = DEFAULT_ROTATION_MAX_SIZE_MB,
173
- recent_hours: int = DEFAULT_ROTATION_RECENT_HOURS,
190
+ max_size_mb: int | None = None,
191
+ recent_hours: int | None = None,
174
192
  now: datetime | None = None,
175
193
  ) -> None:
176
194
  """Rotate the log daily or by size, retaining active/recent workers."""
@@ -179,6 +197,12 @@ def rotate_events_log(
179
197
  return
180
198
 
181
199
  current_ts = now or datetime.now(timezone.utc)
200
+ if max_size_mb is None or recent_hours is None:
201
+ rotation_config = _load_rotation_config()
202
+ if max_size_mb is None:
203
+ max_size_mb = rotation_config.max_size_mb
204
+ if recent_hours is None:
205
+ recent_hours = rotation_config.recent_hours
182
206
 
183
207
  with path.open("r+", encoding="utf-8") as handle:
184
208
  _lock_file(handle)
@@ -6,15 +6,17 @@ This allows claude-team to orchestrate multiple agent types through a unified in
6
6
  """
7
7
 
8
8
  from .base import AgentCLI
9
- from .claude import ClaudeCLI, claude_cli
10
- from .codex import CodexCLI, codex_cli
9
+ from .claude import ClaudeCLI, claude_cli, get_claude_command
10
+ from .codex import CodexCLI, codex_cli, get_codex_command
11
11
 
12
12
  __all__ = [
13
13
  "AgentCLI",
14
14
  "ClaudeCLI",
15
15
  "claude_cli",
16
+ "get_claude_command",
16
17
  "CodexCLI",
17
18
  "codex_cli",
19
+ "get_codex_command",
18
20
  "get_cli_backend",
19
21
  ]
20
22
 
@@ -10,6 +10,45 @@ from typing import Literal
10
10
 
11
11
  from .base import AgentCLI
12
12
 
13
+ # Built-in default command.
14
+ _DEFAULT_COMMAND = "claude"
15
+
16
+ # Environment variable for command override (takes highest precedence).
17
+ _ENV_VAR = "CLAUDE_TEAM_COMMAND"
18
+
19
+
20
+ def get_claude_command() -> str:
21
+ """
22
+ Get the Claude CLI command with precedence: env var > config > default.
23
+
24
+ Resolution order:
25
+ 1. CLAUDE_TEAM_COMMAND environment variable (for override)
26
+ 2. Config file commands.claude setting
27
+ 3. Built-in default "claude"
28
+
29
+ Returns:
30
+ The command to use for Claude CLI
31
+ """
32
+ # Environment variable takes highest precedence (for override).
33
+ env_val = os.environ.get(_ENV_VAR)
34
+ if env_val:
35
+ return env_val
36
+
37
+ # Try config file next.
38
+ # Import here to avoid circular imports and lazy-load config.
39
+ try:
40
+ from ..config import ConfigError, load_config
41
+
42
+ config = load_config()
43
+ except ConfigError:
44
+ return _DEFAULT_COMMAND
45
+
46
+ if config.commands.claude:
47
+ return config.commands.claude
48
+
49
+ # Fall back to built-in default.
50
+ return _DEFAULT_COMMAND
51
+
13
52
 
14
53
  class ClaudeCLI(AgentCLI):
15
54
  """
@@ -31,10 +70,12 @@ class ClaudeCLI(AgentCLI):
31
70
  """
32
71
  Return the Claude CLI command.
33
72
 
34
- Respects CLAUDE_TEAM_COMMAND environment variable for overrides
35
- (e.g., "happy" wrapper).
73
+ Resolution order:
74
+ 1. CLAUDE_TEAM_COMMAND environment variable (for override)
75
+ 2. Config file commands.claude setting
76
+ 3. Built-in default "claude"
36
77
  """
37
- return os.environ.get("CLAUDE_TEAM_COMMAND", "claude")
78
+ return get_claude_command()
38
79
 
39
80
  def build_args(
40
81
  self,
@@ -102,8 +143,7 @@ class ClaudeCLI(AgentCLI):
102
143
 
103
144
  def _is_default_command(self) -> bool:
104
145
  """Check if using the default 'claude' command (not a custom wrapper)."""
105
- cmd = os.environ.get("CLAUDE_TEAM_COMMAND", "claude")
106
- return cmd == "claude"
146
+ return get_claude_command() == _DEFAULT_COMMAND
107
147
 
108
148
 
109
149
  # Singleton instance for convenience
@@ -12,6 +12,45 @@ from typing import Literal
12
12
 
13
13
  from .base import AgentCLI
14
14
 
15
+ # Built-in default command.
16
+ _DEFAULT_COMMAND = "codex"
17
+
18
+ # Environment variable for command override (takes highest precedence).
19
+ _ENV_VAR = "CLAUDE_TEAM_CODEX_COMMAND"
20
+
21
+
22
+ def get_codex_command() -> str:
23
+ """
24
+ Get the Codex CLI command with precedence: env var > config > default.
25
+
26
+ Resolution order:
27
+ 1. CLAUDE_TEAM_CODEX_COMMAND environment variable (for override)
28
+ 2. Config file commands.codex setting
29
+ 3. Built-in default "codex"
30
+
31
+ Returns:
32
+ The command to use for Codex CLI
33
+ """
34
+ # Environment variable takes highest precedence (for override).
35
+ env_val = os.environ.get(_ENV_VAR)
36
+ if env_val:
37
+ return env_val
38
+
39
+ # Try config file next.
40
+ # Import here to avoid circular imports and lazy-load config.
41
+ try:
42
+ from ..config import ConfigError, load_config
43
+
44
+ config = load_config()
45
+ except ConfigError:
46
+ return _DEFAULT_COMMAND
47
+
48
+ if config.commands.codex:
49
+ return config.commands.codex
50
+
51
+ # Fall back to built-in default.
52
+ return _DEFAULT_COMMAND
53
+
15
54
 
16
55
  class CodexCLI(AgentCLI):
17
56
  """
@@ -35,10 +74,12 @@ class CodexCLI(AgentCLI):
35
74
  """
36
75
  Return the Codex CLI command.
37
76
 
38
- Respects CLAUDE_TEAM_CODEX_COMMAND environment variable for overrides
39
- (e.g., "happy codex" wrapper).
77
+ Resolution order:
78
+ 1. CLAUDE_TEAM_CODEX_COMMAND environment variable (for override)
79
+ 2. Config file commands.codex setting
80
+ 3. Built-in default "codex"
40
81
  """
41
- return os.environ.get("CLAUDE_TEAM_CODEX_COMMAND", "codex")
82
+ return get_codex_command()
42
83
 
43
84
  def build_args(
44
85
  self,
@@ -0,0 +1,350 @@
1
+ """
2
+ Configuration loading for Claude Team MCP.
3
+
4
+ Defines dataclasses for the config schema and utilities for loading
5
+ and validating JSON config files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import asdict, dataclass, field
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ CONFIG_VERSION = 1
16
+ CONFIG_DIR = Path.home() / ".claude-team"
17
+ CONFIG_PATH = CONFIG_DIR / "config.json"
18
+
19
+ AgentType = Literal["claude", "codex"]
20
+ LayoutMode = Literal["auto", "new"]
21
+ TerminalBackend = Literal["iterm", "tmux"]
22
+ IssueTrackerName = Literal["beads", "pebbles"]
23
+
24
+
25
+ class ConfigError(ValueError):
26
+ """Raised when the configuration file is invalid."""
27
+
28
+
29
+ @dataclass
30
+ class CommandsConfig:
31
+ """CLI command overrides for supported agent backends."""
32
+
33
+ claude: str | None = None
34
+ codex: str | None = None
35
+
36
+
37
+ @dataclass
38
+ class DefaultsConfig:
39
+ """Default values applied when spawn_workers fields are omitted."""
40
+
41
+ agent_type: AgentType = "claude"
42
+ skip_permissions: bool = False
43
+ use_worktree: bool = True
44
+ layout: LayoutMode = "auto"
45
+
46
+
47
+ @dataclass
48
+ class TerminalConfig:
49
+ """Terminal backend configuration."""
50
+
51
+ backend: TerminalBackend | None = None # None = auto-detect
52
+
53
+
54
+ @dataclass
55
+ class EventsConfig:
56
+ """Event log rotation configuration."""
57
+
58
+ max_size_mb: int = 1
59
+ recent_hours: int = 24
60
+
61
+
62
+ @dataclass
63
+ class IssueTrackerConfig:
64
+ """Issue tracker configuration overrides."""
65
+
66
+ override: IssueTrackerName | None = None
67
+
68
+
69
+ @dataclass
70
+ class ClaudeTeamConfig:
71
+ """Top-level configuration container for claude-team."""
72
+
73
+ version: int = CONFIG_VERSION
74
+ commands: CommandsConfig = field(default_factory=CommandsConfig)
75
+ defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
76
+ terminal: TerminalConfig = field(default_factory=TerminalConfig)
77
+ events: EventsConfig = field(default_factory=EventsConfig)
78
+ issue_tracker: IssueTrackerConfig = field(default_factory=IssueTrackerConfig)
79
+
80
+
81
+ def default_config() -> ClaudeTeamConfig:
82
+ """Return a new config instance with default values."""
83
+
84
+ return ClaudeTeamConfig()
85
+
86
+
87
+ def load_config(config_path: Path | None = None) -> ClaudeTeamConfig:
88
+ """Load config from disk, creating defaults if missing."""
89
+
90
+ path = _resolve_config_path(config_path)
91
+ if not path.exists():
92
+ return default_config()
93
+
94
+ data = _read_json(path)
95
+ return _parse_config(data)
96
+
97
+
98
+ def parse_config(data: dict) -> ClaudeTeamConfig:
99
+ """Parse and validate a config dictionary."""
100
+
101
+ return _parse_config(data)
102
+
103
+
104
+ def save_config(config: ClaudeTeamConfig, config_path: Path | None = None) -> Path:
105
+ """Persist config to disk and return the path written."""
106
+
107
+ path = _resolve_config_path(config_path)
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ payload = json.dumps(asdict(config), indent=2, sort_keys=True)
110
+ path.write_text(payload + "\n")
111
+ return path
112
+
113
+
114
+ def _resolve_config_path(config_path: Path | None) -> Path:
115
+ # Resolve the config path, using the default location if needed.
116
+ return (config_path or CONFIG_PATH).expanduser()
117
+
118
+
119
+ def _read_json(path: Path) -> dict:
120
+ # Read the file contents first so we can surface IO errors cleanly.
121
+ try:
122
+ raw = path.read_text()
123
+ except OSError as exc:
124
+ raise ConfigError(f"Unable to read config file: {path}") from exc
125
+
126
+ # Decode JSON and enforce an object payload.
127
+ try:
128
+ data = json.loads(raw)
129
+ except json.JSONDecodeError as exc:
130
+ raise ConfigError(f"Invalid JSON in config file: {path}") from exc
131
+
132
+ if not isinstance(data, dict):
133
+ raise ConfigError("Config file must contain a JSON object")
134
+
135
+ return data
136
+
137
+
138
+ def _parse_config(data: dict) -> ClaudeTeamConfig:
139
+ # Validate expected top-level keys before parsing sections.
140
+ _validate_keys(
141
+ data,
142
+ {"version", "commands", "defaults", "terminal", "events", "issue_tracker"},
143
+ "config",
144
+ )
145
+ version = _read_version(data.get("version"))
146
+ commands = _parse_commands(data.get("commands"))
147
+ defaults = _parse_defaults(data.get("defaults"))
148
+ terminal = _parse_terminal(data.get("terminal"))
149
+ events = _parse_events(data.get("events"))
150
+ issue_tracker = _parse_issue_tracker(data.get("issue_tracker"))
151
+ return ClaudeTeamConfig(
152
+ version=version,
153
+ commands=commands,
154
+ defaults=defaults,
155
+ terminal=terminal,
156
+ events=events,
157
+ issue_tracker=issue_tracker,
158
+ )
159
+
160
+
161
+ def _read_version(value: object) -> int:
162
+ # Allow missing versions for backward compatibility with early configs.
163
+ if value is None:
164
+ return CONFIG_VERSION
165
+ if not isinstance(value, int):
166
+ raise ConfigError("config.version must be an integer")
167
+ if value != CONFIG_VERSION:
168
+ raise ConfigError(
169
+ f"Unsupported config version {value}; expected {CONFIG_VERSION}"
170
+ )
171
+ return value
172
+
173
+
174
+ def _parse_commands(value: object) -> CommandsConfig:
175
+ # Parse CLI command overrides for each backend.
176
+ data = _ensure_dict(value, "commands")
177
+ _validate_keys(data, {"claude", "codex"}, "commands")
178
+ return CommandsConfig(
179
+ claude=_optional_str(data.get("claude"), "commands.claude"),
180
+ codex=_optional_str(data.get("codex"), "commands.codex"),
181
+ )
182
+
183
+
184
+ def _parse_defaults(value: object) -> DefaultsConfig:
185
+ # Parse default spawn_workers fields with explicit validation.
186
+ data = _ensure_dict(value, "defaults")
187
+ _validate_keys(
188
+ data,
189
+ {"agent_type", "skip_permissions", "use_worktree", "layout"},
190
+ "defaults",
191
+ )
192
+ return DefaultsConfig(
193
+ agent_type=_optional_literal(
194
+ data.get("agent_type"),
195
+ {"claude", "codex"},
196
+ "defaults.agent_type",
197
+ DefaultsConfig.agent_type,
198
+ ),
199
+ skip_permissions=_optional_bool(
200
+ data.get("skip_permissions"),
201
+ "defaults.skip_permissions",
202
+ DefaultsConfig.skip_permissions,
203
+ ),
204
+ use_worktree=_optional_bool(
205
+ data.get("use_worktree"),
206
+ "defaults.use_worktree",
207
+ DefaultsConfig.use_worktree,
208
+ ),
209
+ layout=_optional_literal(
210
+ data.get("layout"),
211
+ {"auto", "new"},
212
+ "defaults.layout",
213
+ DefaultsConfig.layout,
214
+ ),
215
+ )
216
+
217
+
218
+ def _parse_terminal(value: object) -> TerminalConfig:
219
+ # Parse terminal backend configuration.
220
+ data = _ensure_dict(value, "terminal")
221
+ _validate_keys(data, {"backend"}, "terminal")
222
+ return TerminalConfig(
223
+ backend=_optional_literal(
224
+ data.get("backend"),
225
+ {"iterm", "tmux"},
226
+ "terminal.backend",
227
+ None,
228
+ ),
229
+ )
230
+
231
+
232
+ def _parse_events(value: object) -> EventsConfig:
233
+ # Parse event log rotation configuration.
234
+ data = _ensure_dict(value, "events")
235
+ _validate_keys(data, {"max_size_mb", "recent_hours"}, "events")
236
+ return EventsConfig(
237
+ max_size_mb=_optional_int(
238
+ data.get("max_size_mb"),
239
+ "events.max_size_mb",
240
+ EventsConfig.max_size_mb,
241
+ min_value=1,
242
+ ),
243
+ recent_hours=_optional_int(
244
+ data.get("recent_hours"),
245
+ "events.recent_hours",
246
+ EventsConfig.recent_hours,
247
+ min_value=0,
248
+ ),
249
+ )
250
+
251
+
252
+ def _parse_issue_tracker(value: object) -> IssueTrackerConfig:
253
+ # Parse issue tracker overrides.
254
+ data = _ensure_dict(value, "issue_tracker")
255
+ _validate_keys(data, {"override"}, "issue_tracker")
256
+ return IssueTrackerConfig(
257
+ override=_optional_literal(
258
+ data.get("override"),
259
+ {"beads", "pebbles"},
260
+ "issue_tracker.override",
261
+ None,
262
+ )
263
+ )
264
+
265
+
266
+ def _ensure_dict(value: object, path: str) -> dict:
267
+ # Ensure sections are JSON objects, defaulting to empty dicts.
268
+ if value is None:
269
+ return {}
270
+ if not isinstance(value, dict):
271
+ raise ConfigError(f"{path} must be a JSON object")
272
+ return value
273
+
274
+
275
+ def _validate_keys(data: dict, allowed: set[str], path: str) -> None:
276
+ # Reject unexpected keys for a config section.
277
+ unknown = set(data.keys()) - allowed
278
+ if unknown:
279
+ joined = ", ".join(sorted(unknown))
280
+ raise ConfigError(f"Unknown keys in {path}: {joined}")
281
+
282
+
283
+ def _optional_str(value: object, path: str) -> str | None:
284
+ # Validate optional string fields.
285
+ if value is None:
286
+ return None
287
+ if not isinstance(value, str):
288
+ raise ConfigError(f"{path} must be a string")
289
+ if not value.strip():
290
+ raise ConfigError(f"{path} cannot be empty")
291
+ return value
292
+
293
+
294
+ def _optional_int(value: object, path: str, default: int, min_value: int = 1) -> int:
295
+ # Validate optional integer fields.
296
+ if value is None:
297
+ return default
298
+ if not isinstance(value, int) or isinstance(value, bool):
299
+ raise ConfigError(f"{path} must be an integer")
300
+ if value < min_value:
301
+ raise ConfigError(f"{path} must be at least {min_value}")
302
+ return value
303
+
304
+
305
+ def _optional_bool(value: object, path: str, default: bool) -> bool:
306
+ # Validate optional boolean fields.
307
+ if value is None:
308
+ return default
309
+ if not isinstance(value, bool):
310
+ raise ConfigError(f"{path} must be a boolean")
311
+ return value
312
+
313
+
314
+ def _optional_literal(
315
+ value: object,
316
+ allowed: set[str],
317
+ path: str,
318
+ default: str | None,
319
+ ) -> str | None:
320
+ # Validate optional string fields constrained to allowed values.
321
+ if value is None:
322
+ return default
323
+ if not isinstance(value, str):
324
+ raise ConfigError(f"{path} must be a string")
325
+ if value not in allowed:
326
+ joined = ", ".join(sorted(allowed))
327
+ raise ConfigError(f"{path} must be one of: {joined}")
328
+ return value
329
+
330
+
331
+ __all__ = [
332
+ "AgentType",
333
+ "ClaudeTeamConfig",
334
+ "CommandsConfig",
335
+ "ConfigError",
336
+ "DefaultsConfig",
337
+ "EventsConfig",
338
+ "IssueTrackerConfig",
339
+ "LayoutMode",
340
+ "TerminalBackend",
341
+ "TerminalConfig",
342
+ "IssueTrackerName",
343
+ "CONFIG_DIR",
344
+ "CONFIG_PATH",
345
+ "CONFIG_VERSION",
346
+ "default_config",
347
+ "load_config",
348
+ "parse_config",
349
+ "save_config",
350
+ ]