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 +30 -6
- claude_team_mcp/cli_backends/__init__.py +4 -2
- claude_team_mcp/cli_backends/claude.py +45 -5
- claude_team_mcp/cli_backends/codex.py +44 -3
- claude_team_mcp/config.py +350 -0
- claude_team_mcp/config_cli.py +263 -0
- claude_team_mcp/issue_tracker/__init__.py +68 -3
- claude_team_mcp/server.py +69 -0
- claude_team_mcp/terminal_backends/__init__.py +21 -3
- claude_team_mcp/terminal_backends/tmux.py +59 -24
- claude_team_mcp/tools/discover_workers.py +1 -1
- claude_team_mcp/tools/spawn_workers.py +36 -14
- claude_team_mcp/worktree.py +16 -2
- {claude_team_mcp-0.7.0.dist-info → claude_team_mcp-0.8.2.dist-info}/METADATA +1 -1
- {claude_team_mcp-0.7.0.dist-info → claude_team_mcp-0.8.2.dist-info}/RECORD +17 -15
- {claude_team_mcp-0.7.0.dist-info → claude_team_mcp-0.8.2.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.7.0.dist-info → claude_team_mcp-0.8.2.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI helpers for claude-team configuration commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
|
|
13
|
+
from . import config as config_module
|
|
14
|
+
from .config import ClaudeTeamConfig, ConfigError, parse_config
|
|
15
|
+
|
|
16
|
+
_ALLOWED_AGENT_TYPES = {"claude", "codex"}
|
|
17
|
+
_ALLOWED_LAYOUTS = {"auto", "new"}
|
|
18
|
+
_ALLOWED_TERMINAL_BACKENDS = {"iterm", "tmux"}
|
|
19
|
+
_ALLOWED_ISSUE_TRACKERS = {"beads", "pebbles"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init_config(
|
|
23
|
+
*,
|
|
24
|
+
force: bool = False,
|
|
25
|
+
config_path: Path | None = None,
|
|
26
|
+
) -> Path:
|
|
27
|
+
"""Write the default config file to disk and return the path."""
|
|
28
|
+
|
|
29
|
+
path = _resolve_config_path(config_path)
|
|
30
|
+
if path.exists() and not force:
|
|
31
|
+
raise ConfigError(f"Config file already exists: {path}")
|
|
32
|
+
config = config_module.default_config()
|
|
33
|
+
return config_module.save_config(config, path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_effective_config_data(
|
|
37
|
+
*,
|
|
38
|
+
env: Mapping[str, str] | None = None,
|
|
39
|
+
config_path: Path | None = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Load config data with environment overrides applied."""
|
|
42
|
+
|
|
43
|
+
config = config_module.load_config(config_path)
|
|
44
|
+
data = asdict(config)
|
|
45
|
+
_apply_env_overrides(data, env or os.environ)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_config_json(
|
|
50
|
+
*,
|
|
51
|
+
env: Mapping[str, str] | None = None,
|
|
52
|
+
config_path: Path | None = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Render the effective config as formatted JSON."""
|
|
55
|
+
|
|
56
|
+
data = load_effective_config_data(env=env, config_path=config_path)
|
|
57
|
+
return json.dumps(data, indent=2, sort_keys=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_config_value(
|
|
61
|
+
key: str,
|
|
62
|
+
*,
|
|
63
|
+
env: Mapping[str, str] | None = None,
|
|
64
|
+
config_path: Path | None = None,
|
|
65
|
+
) -> object:
|
|
66
|
+
"""Return a single config value by dotted path."""
|
|
67
|
+
|
|
68
|
+
data = load_effective_config_data(env=env, config_path=config_path)
|
|
69
|
+
return _get_nested_value(data, key)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_config_value(
|
|
73
|
+
key: str,
|
|
74
|
+
raw_value: str,
|
|
75
|
+
*,
|
|
76
|
+
config_path: Path | None = None,
|
|
77
|
+
) -> ClaudeTeamConfig:
|
|
78
|
+
"""Set a config value by dotted path, validate, and persist."""
|
|
79
|
+
|
|
80
|
+
config = config_module.load_config(config_path)
|
|
81
|
+
data = asdict(config)
|
|
82
|
+
parsed_value = _parse_cli_value(key, raw_value)
|
|
83
|
+
_set_nested_value(data, key, parsed_value)
|
|
84
|
+
updated = parse_config(data)
|
|
85
|
+
config_module.save_config(updated, _resolve_config_path(config_path))
|
|
86
|
+
return updated
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_value_json(value: object) -> str:
|
|
90
|
+
"""Format a single config value as JSON."""
|
|
91
|
+
|
|
92
|
+
return json.dumps(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_config_path(config_path: Path | None) -> Path:
|
|
96
|
+
# Resolve the config path, defaulting to ~/.claude-team/config.json.
|
|
97
|
+
return (config_path or config_module.CONFIG_PATH).expanduser()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_env_overrides(data: dict, env: Mapping[str, str]) -> None:
|
|
101
|
+
# Apply env overrides using the same precedence logic as runtime helpers.
|
|
102
|
+
command_override = env.get("CLAUDE_TEAM_COMMAND")
|
|
103
|
+
if command_override:
|
|
104
|
+
data["commands"]["claude"] = command_override
|
|
105
|
+
|
|
106
|
+
codex_override = env.get("CLAUDE_TEAM_CODEX_COMMAND")
|
|
107
|
+
if codex_override:
|
|
108
|
+
data["commands"]["codex"] = codex_override
|
|
109
|
+
|
|
110
|
+
# Terminal backend is a direct override (mirrors select_backend_id).
|
|
111
|
+
backend_override = env.get("CLAUDE_TEAM_TERMINAL_BACKEND")
|
|
112
|
+
if backend_override:
|
|
113
|
+
data["terminal"]["backend"] = backend_override.strip().lower()
|
|
114
|
+
|
|
115
|
+
# Issue tracker override mirrors detect_issue_tracker validation.
|
|
116
|
+
tracker_override = env.get("CLAUDE_TEAM_ISSUE_TRACKER")
|
|
117
|
+
if tracker_override:
|
|
118
|
+
normalized = tracker_override.strip().lower()
|
|
119
|
+
if normalized in _ALLOWED_ISSUE_TRACKERS:
|
|
120
|
+
data["issue_tracker"]["override"] = normalized
|
|
121
|
+
|
|
122
|
+
# Events overrides use integer parsing with graceful fallback.
|
|
123
|
+
max_size_override = env.get("CLAUDE_TEAM_EVENTS_MAX_SIZE_MB")
|
|
124
|
+
if max_size_override:
|
|
125
|
+
parsed = _parse_int_override(max_size_override)
|
|
126
|
+
if parsed is not None:
|
|
127
|
+
data["events"]["max_size_mb"] = parsed
|
|
128
|
+
|
|
129
|
+
recent_hours_override = env.get("CLAUDE_TEAM_EVENTS_RECENT_HOURS")
|
|
130
|
+
if recent_hours_override:
|
|
131
|
+
parsed = _parse_int_override(recent_hours_override)
|
|
132
|
+
if parsed is not None:
|
|
133
|
+
data["events"]["recent_hours"] = parsed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_int_override(raw_value: str) -> int | None:
|
|
137
|
+
# Parse env overrides as integers; invalid values are ignored.
|
|
138
|
+
try:
|
|
139
|
+
return int(raw_value)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_cli_value(key: str, raw_value: str) -> object:
|
|
145
|
+
# Parse CLI values according to the config schema.
|
|
146
|
+
parser = _FIELD_PARSERS.get(key)
|
|
147
|
+
if parser is None:
|
|
148
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
149
|
+
return parser(raw_value, key)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_optional_string(raw_value: str, field: str) -> str | None:
|
|
153
|
+
# Parse optional string fields (allows null).
|
|
154
|
+
if _is_null(raw_value):
|
|
155
|
+
return None
|
|
156
|
+
if not raw_value.strip():
|
|
157
|
+
raise ConfigError(f"{field} cannot be empty")
|
|
158
|
+
return raw_value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_bool(raw_value: str, field: str) -> bool:
|
|
162
|
+
# Parse boolean values in JSON-compatible form.
|
|
163
|
+
normalized = raw_value.strip().lower()
|
|
164
|
+
if normalized == "true":
|
|
165
|
+
return True
|
|
166
|
+
if normalized == "false":
|
|
167
|
+
return False
|
|
168
|
+
raise ConfigError(f"{field} must be a boolean")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_int(raw_value: str, field: str) -> int:
|
|
172
|
+
# Parse integer values with minimum validation.
|
|
173
|
+
try:
|
|
174
|
+
value = int(raw_value.strip())
|
|
175
|
+
except ValueError as exc:
|
|
176
|
+
raise ConfigError(f"{field} must be an integer") from exc
|
|
177
|
+
if value < 1:
|
|
178
|
+
raise ConfigError(f"{field} must be at least 1")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_literal(raw_value: str, field: str, allowed: set[str]) -> str:
|
|
183
|
+
# Parse literal string values constrained to allowed sets.
|
|
184
|
+
value = raw_value.strip()
|
|
185
|
+
if value not in allowed:
|
|
186
|
+
joined = ", ".join(sorted(allowed))
|
|
187
|
+
raise ConfigError(f"{field} must be one of: {joined}")
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_optional_literal(
|
|
192
|
+
raw_value: str,
|
|
193
|
+
field: str,
|
|
194
|
+
allowed: set[str],
|
|
195
|
+
) -> str | None:
|
|
196
|
+
# Parse nullable literal values constrained to allowed sets.
|
|
197
|
+
if _is_null(raw_value):
|
|
198
|
+
return None
|
|
199
|
+
return _parse_literal(raw_value, field, allowed)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_null(raw_value: str) -> bool:
|
|
203
|
+
# Treat "null" as a request to clear optional fields.
|
|
204
|
+
return raw_value.strip().lower() == "null"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_nested_value(data: dict, key: str) -> object:
|
|
208
|
+
# Retrieve values by dotted path, validating against known keys.
|
|
209
|
+
if key not in _GET_KEYS:
|
|
210
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
211
|
+
parts = key.split(".")
|
|
212
|
+
current: object = data
|
|
213
|
+
for part in parts:
|
|
214
|
+
if not isinstance(current, dict) or part not in current:
|
|
215
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
216
|
+
current = current[part]
|
|
217
|
+
return current
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _set_nested_value(data: dict, key: str, value: object) -> None:
|
|
221
|
+
# Assign values by dotted path, ensuring the key exists.
|
|
222
|
+
parts = key.split(".")
|
|
223
|
+
current: dict = data
|
|
224
|
+
for part in parts[:-1]:
|
|
225
|
+
if part not in current or not isinstance(current[part], dict):
|
|
226
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
227
|
+
current = current[part]
|
|
228
|
+
leaf = parts[-1]
|
|
229
|
+
if leaf not in current:
|
|
230
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
231
|
+
current[leaf] = value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_FIELD_PARSERS: dict[str, Callable[[str, str], object]] = {
|
|
235
|
+
"commands.claude": _parse_optional_string,
|
|
236
|
+
"commands.codex": _parse_optional_string,
|
|
237
|
+
"defaults.agent_type": lambda value, field: _parse_literal(
|
|
238
|
+
value,
|
|
239
|
+
field,
|
|
240
|
+
_ALLOWED_AGENT_TYPES,
|
|
241
|
+
),
|
|
242
|
+
"defaults.skip_permissions": _parse_bool,
|
|
243
|
+
"defaults.use_worktree": _parse_bool,
|
|
244
|
+
"defaults.layout": lambda value, field: _parse_literal(
|
|
245
|
+
value,
|
|
246
|
+
field,
|
|
247
|
+
_ALLOWED_LAYOUTS,
|
|
248
|
+
),
|
|
249
|
+
"terminal.backend": lambda value, field: _parse_optional_literal(
|
|
250
|
+
value,
|
|
251
|
+
field,
|
|
252
|
+
_ALLOWED_TERMINAL_BACKENDS,
|
|
253
|
+
),
|
|
254
|
+
"events.max_size_mb": _parse_int,
|
|
255
|
+
"events.recent_hours": _parse_int,
|
|
256
|
+
"issue_tracker.override": lambda value, field: _parse_optional_literal(
|
|
257
|
+
value,
|
|
258
|
+
field,
|
|
259
|
+
_ALLOWED_ISSUE_TRACKERS,
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_GET_KEYS = set(_FIELD_PARSERS.keys()) | {"version"}
|
|
@@ -9,10 +9,16 @@ from __future__ import annotations
|
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
|
-
from typing import Protocol, runtime_checkable
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from claude_team_mcp.config import ClaudeTeamConfig
|
|
13
16
|
|
|
14
17
|
logger = logging.getLogger("claude-team-mcp")
|
|
15
18
|
|
|
19
|
+
# Environment variable for explicit tracker override (highest priority).
|
|
20
|
+
ISSUE_TRACKER_ENV_VAR = "CLAUDE_TEAM_ISSUE_TRACKER"
|
|
21
|
+
|
|
16
22
|
|
|
17
23
|
@runtime_checkable
|
|
18
24
|
class IssueTrackerBackend(Protocol):
|
|
@@ -86,16 +92,74 @@ BACKEND_REGISTRY: dict[str, IssueTrackerBackend] = {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
|
|
89
|
-
def detect_issue_tracker(
|
|
95
|
+
def detect_issue_tracker(
|
|
96
|
+
project_path: str,
|
|
97
|
+
config: ClaudeTeamConfig | None = None,
|
|
98
|
+
) -> IssueTrackerBackend | None:
|
|
90
99
|
"""
|
|
91
100
|
Detect the issue tracker backend for the given project path.
|
|
92
101
|
|
|
102
|
+
Resolution order (highest to lowest priority):
|
|
103
|
+
1. CLAUDE_TEAM_ISSUE_TRACKER environment variable
|
|
104
|
+
2. config.issue_tracker.override setting
|
|
105
|
+
3. Marker directory detection (.pebbles, .beads)
|
|
106
|
+
|
|
93
107
|
Args:
|
|
94
108
|
project_path: Absolute or relative path to the project root.
|
|
109
|
+
config: Optional config object. If None, config is loaded from disk.
|
|
95
110
|
|
|
96
111
|
Returns:
|
|
97
|
-
The detected IssueTrackerBackend, or None if no
|
|
112
|
+
The detected IssueTrackerBackend, or None if no tracker is configured
|
|
113
|
+
or detected.
|
|
98
114
|
"""
|
|
115
|
+
# Priority 1: Environment variable override.
|
|
116
|
+
env_override = os.environ.get(ISSUE_TRACKER_ENV_VAR)
|
|
117
|
+
if env_override:
|
|
118
|
+
backend = BACKEND_REGISTRY.get(env_override.lower())
|
|
119
|
+
if backend:
|
|
120
|
+
logger.debug(
|
|
121
|
+
"Using issue tracker '%s' from %s env var",
|
|
122
|
+
backend.name,
|
|
123
|
+
ISSUE_TRACKER_ENV_VAR,
|
|
124
|
+
)
|
|
125
|
+
return backend
|
|
126
|
+
logger.warning(
|
|
127
|
+
"Unknown issue tracker '%s' in %s; ignoring",
|
|
128
|
+
env_override,
|
|
129
|
+
ISSUE_TRACKER_ENV_VAR,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Priority 2: Config file override.
|
|
133
|
+
if config is None:
|
|
134
|
+
# Lazy import to avoid circular dependency at module load time.
|
|
135
|
+
try:
|
|
136
|
+
from claude_team_mcp.config import ConfigError, load_config
|
|
137
|
+
|
|
138
|
+
config = load_config()
|
|
139
|
+
except ConfigError as exc:
|
|
140
|
+
logger.warning("Invalid config file; ignoring overrides: %s", exc)
|
|
141
|
+
config = None
|
|
142
|
+
|
|
143
|
+
if config and config.issue_tracker.override:
|
|
144
|
+
backend = BACKEND_REGISTRY.get(config.issue_tracker.override)
|
|
145
|
+
if backend:
|
|
146
|
+
logger.debug(
|
|
147
|
+
"Using issue tracker '%s' from config override",
|
|
148
|
+
backend.name,
|
|
149
|
+
)
|
|
150
|
+
return backend
|
|
151
|
+
# Config validation should prevent this, but handle gracefully.
|
|
152
|
+
logger.warning(
|
|
153
|
+
"Unknown issue tracker '%s' in config; ignoring",
|
|
154
|
+
config.issue_tracker.override,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Priority 3: Marker directory detection.
|
|
158
|
+
return _detect_from_markers(project_path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _detect_from_markers(project_path: str) -> IssueTrackerBackend | None:
|
|
162
|
+
"""Detect issue tracker by checking for marker directories."""
|
|
99
163
|
beads_marker = os.path.join(project_path, BEADS_BACKEND.marker_dir)
|
|
100
164
|
pebbles_marker = os.path.join(project_path, PEBBLES_BACKEND.marker_dir)
|
|
101
165
|
|
|
@@ -128,5 +192,6 @@ __all__ = [
|
|
|
128
192
|
"BEADS_BACKEND",
|
|
129
193
|
"PEBBLES_BACKEND",
|
|
130
194
|
"BACKEND_REGISTRY",
|
|
195
|
+
"ISSUE_TRACKER_ENV_VAR",
|
|
131
196
|
"detect_issue_tracker",
|
|
132
197
|
]
|
claude_team_mcp/server.py
CHANGED
|
@@ -398,8 +398,10 @@ def run_server(transport: str = "stdio", port: int = 8766):
|
|
|
398
398
|
def main():
|
|
399
399
|
"""CLI entry point with argument parsing."""
|
|
400
400
|
import argparse
|
|
401
|
+
import sys
|
|
401
402
|
|
|
402
403
|
parser = argparse.ArgumentParser(description="Claude Team MCP Server")
|
|
404
|
+
# Global server options apply when no subcommand is provided.
|
|
403
405
|
parser.add_argument(
|
|
404
406
|
"--http",
|
|
405
407
|
action="store_true",
|
|
@@ -411,9 +413,76 @@ def main():
|
|
|
411
413
|
default=8766,
|
|
412
414
|
help="Port for HTTP mode (default: 8766)",
|
|
413
415
|
)
|
|
416
|
+
# Config subcommands for reading/writing ~/.claude-team/config.json.
|
|
417
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
418
|
+
|
|
419
|
+
config_parser = subparsers.add_parser(
|
|
420
|
+
"config",
|
|
421
|
+
help="Manage claude-team configuration",
|
|
422
|
+
)
|
|
423
|
+
config_subparsers = config_parser.add_subparsers(dest="config_command")
|
|
424
|
+
|
|
425
|
+
init_parser = config_subparsers.add_parser(
|
|
426
|
+
"init",
|
|
427
|
+
help="Write default config to disk",
|
|
428
|
+
)
|
|
429
|
+
init_parser.add_argument(
|
|
430
|
+
"--force",
|
|
431
|
+
action="store_true",
|
|
432
|
+
help="Overwrite existing config file",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
config_subparsers.add_parser(
|
|
436
|
+
"show",
|
|
437
|
+
help="Show effective config (file + env overrides)",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
get_parser = config_subparsers.add_parser(
|
|
441
|
+
"get",
|
|
442
|
+
help="Get a single config value by dotted path",
|
|
443
|
+
)
|
|
444
|
+
get_parser.add_argument("key", help="Dotted config key (e.g. defaults.layout)")
|
|
445
|
+
|
|
446
|
+
set_parser = config_subparsers.add_parser(
|
|
447
|
+
"set",
|
|
448
|
+
help="Set a single config value by dotted path",
|
|
449
|
+
)
|
|
450
|
+
set_parser.add_argument("key", help="Dotted config key (e.g. defaults.layout)")
|
|
451
|
+
set_parser.add_argument("value", help="Value to set")
|
|
414
452
|
|
|
415
453
|
args = parser.parse_args()
|
|
416
454
|
|
|
455
|
+
# Handle config subcommands early to avoid starting the server.
|
|
456
|
+
if args.command == "config":
|
|
457
|
+
from .config import ConfigError
|
|
458
|
+
from .config_cli import (
|
|
459
|
+
format_value_json,
|
|
460
|
+
get_config_value,
|
|
461
|
+
init_config,
|
|
462
|
+
render_config_json,
|
|
463
|
+
set_config_value,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
if args.config_command == "init":
|
|
468
|
+
path = init_config(force=args.force)
|
|
469
|
+
print(path)
|
|
470
|
+
elif args.config_command == "show":
|
|
471
|
+
print(render_config_json())
|
|
472
|
+
elif args.config_command == "get":
|
|
473
|
+
value = get_config_value(args.key)
|
|
474
|
+
print(format_value_json(value))
|
|
475
|
+
elif args.config_command == "set":
|
|
476
|
+
set_config_value(args.key, args.value)
|
|
477
|
+
else:
|
|
478
|
+
config_parser.print_help()
|
|
479
|
+
raise SystemExit(2)
|
|
480
|
+
except ConfigError as exc:
|
|
481
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
482
|
+
raise SystemExit(1) from exc
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
# Default behavior: run the MCP server.
|
|
417
486
|
if args.http:
|
|
418
487
|
run_server(transport="streamable-http", port=args.port)
|
|
419
488
|
else:
|
|
@@ -2,18 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
7
|
from typing import Mapping
|
|
7
8
|
|
|
9
|
+
from ..config import ClaudeTeamConfig, ConfigError, load_config
|
|
8
10
|
from .base import TerminalBackend, TerminalSession
|
|
9
11
|
from .iterm import ItermBackend, MAX_PANES_PER_TAB
|
|
10
12
|
from .tmux import TmuxBackend
|
|
11
13
|
|
|
14
|
+
logger = logging.getLogger("claude-team-mcp")
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
def select_backend_id(
|
|
18
|
+
env: Mapping[str, str] | None = None,
|
|
19
|
+
config: ClaudeTeamConfig | None = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Select a terminal backend id based on environment and config."""
|
|
22
|
+
environ = os.environ if env is None else env
|
|
16
23
|
configured = environ.get("CLAUDE_TEAM_TERMINAL_BACKEND")
|
|
24
|
+
if configured:
|
|
25
|
+
return configured.strip().lower()
|
|
26
|
+
if config is None:
|
|
27
|
+
try:
|
|
28
|
+
config = load_config()
|
|
29
|
+
except ConfigError as exc:
|
|
30
|
+
logger.warning(
|
|
31
|
+
"Invalid config file; ignoring terminal backend override: %s", exc
|
|
32
|
+
)
|
|
33
|
+
config = None
|
|
34
|
+
configured = config.terminal.backend if config else None
|
|
17
35
|
if configured:
|
|
18
36
|
return configured.strip().lower()
|
|
19
37
|
if environ.get("TMUX"):
|
|
@@ -44,7 +44,10 @@ ISSUE_ID_PATTERN = re.compile(r"\b[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]*\d[A-Za-z0-9]
|
|
|
44
44
|
|
|
45
45
|
SHELL_READY_MARKER = "CLAUDE_TEAM_READY_7f3a9c"
|
|
46
46
|
CODEX_PRE_ENTER_DELAY = 0.5
|
|
47
|
-
|
|
47
|
+
TMUX_SESSION_PREFIX = "claude-team"
|
|
48
|
+
TMUX_SESSION_SLUG_MAX = 32
|
|
49
|
+
TMUX_SESSION_FALLBACK = "project"
|
|
50
|
+
TMUX_SESSION_PREFIXED = f"{TMUX_SESSION_PREFIX}-"
|
|
48
51
|
|
|
49
52
|
LAYOUT_PANE_NAMES = {
|
|
50
53
|
"single": ["main"],
|
|
@@ -62,6 +65,47 @@ LAYOUT_SELECT = {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
|
|
68
|
+
# Normalize a project name into a tmux-safe slug.
|
|
69
|
+
def _tmux_safe_slug(value: str) -> str:
|
|
70
|
+
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip())
|
|
71
|
+
slug = slug.strip("-_")
|
|
72
|
+
if not slug:
|
|
73
|
+
return TMUX_SESSION_FALLBACK
|
|
74
|
+
if len(slug) > TMUX_SESSION_SLUG_MAX:
|
|
75
|
+
slug = slug[:TMUX_SESSION_SLUG_MAX].rstrip("-_")
|
|
76
|
+
return slug or TMUX_SESSION_FALLBACK
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def project_name_from_path(project_path: str | None) -> str | None:
|
|
80
|
+
"""Return a display name for a project path, handling worktree paths."""
|
|
81
|
+
if not project_path:
|
|
82
|
+
return None
|
|
83
|
+
path = Path(project_path)
|
|
84
|
+
parts = path.parts
|
|
85
|
+
if ".worktrees" in parts:
|
|
86
|
+
worktrees_index = parts.index(".worktrees")
|
|
87
|
+
if worktrees_index > 0:
|
|
88
|
+
return parts[worktrees_index - 1]
|
|
89
|
+
return path.name
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def tmux_session_name_for_project(project_path: str | None) -> str:
|
|
93
|
+
"""Return the per-project tmux session name for a given project path.
|
|
94
|
+
|
|
95
|
+
Worktree paths produce the same session name as their main repository
|
|
96
|
+
since project_name_from_path extracts the project name from the path.
|
|
97
|
+
Session names follow the format: claude-team-{project-slug}
|
|
98
|
+
"""
|
|
99
|
+
project_name = project_name_from_path(project_path) or TMUX_SESSION_FALLBACK
|
|
100
|
+
slug = _tmux_safe_slug(project_name)
|
|
101
|
+
return f"{TMUX_SESSION_PREFIXED}{slug}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Determine whether a tmux session is managed by claude-team.
|
|
105
|
+
def _is_managed_session_name(session_name: str) -> bool:
|
|
106
|
+
return session_name.startswith(TMUX_SESSION_PREFIXED)
|
|
107
|
+
|
|
108
|
+
|
|
65
109
|
class TmuxBackend(TerminalBackend):
|
|
66
110
|
"""Terminal backend adapter for tmux."""
|
|
67
111
|
|
|
@@ -94,23 +138,24 @@ class TmuxBackend(TerminalBackend):
|
|
|
94
138
|
profile: str | None = None,
|
|
95
139
|
profile_customizations: Any | None = None,
|
|
96
140
|
) -> TerminalSession:
|
|
97
|
-
"""Create a worker window in
|
|
141
|
+
"""Create a worker window in a per-project tmux session."""
|
|
98
142
|
if profile or profile_customizations:
|
|
99
143
|
raise ValueError("tmux backend does not support profiles")
|
|
100
144
|
|
|
101
145
|
base_name = name or self._generate_window_name()
|
|
102
|
-
project_name =
|
|
146
|
+
project_name = project_name_from_path(project_path)
|
|
103
147
|
resolved_issue_id = self._resolve_issue_id(issue_id, coordinator_annotation)
|
|
104
148
|
window_name = self._format_window_name(base_name, project_name, resolved_issue_id)
|
|
149
|
+
session_name = tmux_session_name_for_project(project_path)
|
|
105
150
|
|
|
106
151
|
# Ensure the dedicated session exists, then create a new window for this worker.
|
|
107
152
|
try:
|
|
108
|
-
await self._run_tmux(["has-session", "-t",
|
|
153
|
+
await self._run_tmux(["has-session", "-t", session_name])
|
|
109
154
|
output = await self._run_tmux(
|
|
110
155
|
[
|
|
111
156
|
"new-window",
|
|
112
157
|
"-t",
|
|
113
|
-
|
|
158
|
+
session_name,
|
|
114
159
|
"-n",
|
|
115
160
|
window_name,
|
|
116
161
|
"-P",
|
|
@@ -124,7 +169,7 @@ class TmuxBackend(TerminalBackend):
|
|
|
124
169
|
"new-session",
|
|
125
170
|
"-d",
|
|
126
171
|
"-s",
|
|
127
|
-
|
|
172
|
+
session_name,
|
|
128
173
|
"-n",
|
|
129
174
|
window_name,
|
|
130
175
|
"-P",
|
|
@@ -138,7 +183,7 @@ class TmuxBackend(TerminalBackend):
|
|
|
138
183
|
raise RuntimeError("Failed to determine tmux pane id for new window")
|
|
139
184
|
|
|
140
185
|
metadata = {
|
|
141
|
-
"session_name":
|
|
186
|
+
"session_name": session_name,
|
|
142
187
|
"window_id": window_id,
|
|
143
188
|
"window_index": window_index,
|
|
144
189
|
"window_name": window_name,
|
|
@@ -295,13 +340,12 @@ class TmuxBackend(TerminalBackend):
|
|
|
295
340
|
return panes
|
|
296
341
|
|
|
297
342
|
async def list_sessions(self) -> list[TerminalSession]:
|
|
298
|
-
"""List all tmux panes in
|
|
343
|
+
"""List all tmux panes in claude-team-managed sessions."""
|
|
299
344
|
try:
|
|
300
345
|
output = await self._run_tmux(
|
|
301
346
|
[
|
|
302
347
|
"list-panes",
|
|
303
|
-
"-
|
|
304
|
-
TMUX_SESSION_NAME,
|
|
348
|
+
"-a",
|
|
305
349
|
"-F",
|
|
306
350
|
"#{session_name}\t#{window_id}\t#{window_name}\t#{window_index}\t#{pane_index}\t#{pane_id}",
|
|
307
351
|
]
|
|
@@ -320,6 +364,8 @@ class TmuxBackend(TerminalBackend):
|
|
|
320
364
|
if len(parts) != 6:
|
|
321
365
|
continue
|
|
322
366
|
session_name, window_id, window_name, window_index, pane_index, pane_id = parts
|
|
367
|
+
if not _is_managed_session_name(session_name):
|
|
368
|
+
continue
|
|
323
369
|
sessions.append(
|
|
324
370
|
TerminalSession(
|
|
325
371
|
backend_id=self.backend_id,
|
|
@@ -349,8 +395,7 @@ class TmuxBackend(TerminalBackend):
|
|
|
349
395
|
output = await self._run_tmux(
|
|
350
396
|
[
|
|
351
397
|
"list-panes",
|
|
352
|
-
"-
|
|
353
|
-
TMUX_SESSION_NAME,
|
|
398
|
+
"-a",
|
|
354
399
|
"-F",
|
|
355
400
|
"#{session_name}\t#{window_id}\t#{window_index}\t#{pane_index}\t#{pane_active}\t#{pane_id}",
|
|
356
401
|
]
|
|
@@ -368,6 +413,8 @@ class TmuxBackend(TerminalBackend):
|
|
|
368
413
|
if len(parts) != 6:
|
|
369
414
|
continue
|
|
370
415
|
session_name, window_id, window_index, pane_index, pane_active, pane_id = parts
|
|
416
|
+
if not _is_managed_session_name(session_name):
|
|
417
|
+
continue
|
|
371
418
|
panes_by_window.setdefault((session_name, window_id, window_index), []).append(
|
|
372
419
|
{
|
|
373
420
|
"pane_id": pane_id,
|
|
@@ -576,18 +623,6 @@ class TmuxBackend(TerminalBackend):
|
|
|
576
623
|
|
|
577
624
|
return False
|
|
578
625
|
|
|
579
|
-
# Extract a display name for the project from a project path.
|
|
580
|
-
def _project_name_from_path(self, project_path: str | None) -> str | None:
|
|
581
|
-
if not project_path:
|
|
582
|
-
return None
|
|
583
|
-
path = Path(project_path)
|
|
584
|
-
parts = path.parts
|
|
585
|
-
if ".worktrees" in parts:
|
|
586
|
-
worktrees_index = parts.index(".worktrees")
|
|
587
|
-
if worktrees_index > 0:
|
|
588
|
-
return parts[worktrees_index - 1]
|
|
589
|
-
return path.name
|
|
590
|
-
|
|
591
626
|
# Resolve an issue id from explicit input or a coordinator annotation.
|
|
592
627
|
def _resolve_issue_id(
|
|
593
628
|
self,
|
|
@@ -43,7 +43,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
43
43
|
by claude-team write their terminal IDs into the JSONL
|
|
44
44
|
(e.g., <!claude-team-iterm:UUID!> or <!claude-team-tmux:%1!>), enabling
|
|
45
45
|
reliable detection and recovery after MCP server restarts.
|
|
46
|
-
For tmux, only panes in
|
|
46
|
+
For tmux, only panes in claude-team-managed sessions are scanned.
|
|
47
47
|
|
|
48
48
|
Only JSONL files modified within max_age seconds are checked. If a session
|
|
49
49
|
was started more than max_age seconds ago and hasn't had recent activity,
|