claude-mpm 5.6.1__py3-none-any.whl → 5.6.76__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_mpm/VERSION +1 -1
- claude_mpm/agents/PM_INSTRUCTIONS.md +8 -3
- claude_mpm/auth/__init__.py +35 -0
- claude_mpm/auth/callback_server.py +328 -0
- claude_mpm/auth/models.py +104 -0
- claude_mpm/auth/oauth_manager.py +266 -0
- claude_mpm/auth/providers/__init__.py +12 -0
- claude_mpm/auth/providers/base.py +165 -0
- claude_mpm/auth/providers/google.py +261 -0
- claude_mpm/auth/token_storage.py +252 -0
- claude_mpm/cli/commands/commander.py +174 -4
- claude_mpm/cli/commands/mcp.py +29 -17
- claude_mpm/cli/commands/mcp_command_router.py +39 -0
- claude_mpm/cli/commands/mcp_service_commands.py +304 -0
- claude_mpm/cli/commands/oauth.py +481 -0
- claude_mpm/cli/commands/skill_source.py +51 -2
- claude_mpm/cli/commands/skills.py +5 -3
- claude_mpm/cli/executor.py +9 -0
- claude_mpm/cli/helpers.py +1 -1
- claude_mpm/cli/parsers/base_parser.py +13 -0
- claude_mpm/cli/parsers/commander_parser.py +43 -10
- claude_mpm/cli/parsers/mcp_parser.py +79 -0
- claude_mpm/cli/parsers/oauth_parser.py +165 -0
- claude_mpm/cli/parsers/skill_source_parser.py +4 -0
- claude_mpm/cli/parsers/skills_parser.py +5 -0
- claude_mpm/cli/startup.py +300 -33
- claude_mpm/cli/startup_display.py +4 -2
- claude_mpm/cli/startup_migrations.py +236 -0
- claude_mpm/commander/__init__.py +6 -0
- claude_mpm/commander/adapters/__init__.py +32 -3
- claude_mpm/commander/adapters/auggie.py +260 -0
- claude_mpm/commander/adapters/base.py +98 -1
- claude_mpm/commander/adapters/claude_code.py +32 -1
- claude_mpm/commander/adapters/codex.py +237 -0
- claude_mpm/commander/adapters/example_usage.py +310 -0
- claude_mpm/commander/adapters/mpm.py +389 -0
- claude_mpm/commander/adapters/registry.py +204 -0
- claude_mpm/commander/api/app.py +32 -16
- claude_mpm/commander/api/errors.py +21 -0
- claude_mpm/commander/api/routes/messages.py +11 -11
- claude_mpm/commander/api/routes/projects.py +20 -20
- claude_mpm/commander/api/routes/sessions.py +37 -26
- claude_mpm/commander/api/routes/work.py +86 -50
- claude_mpm/commander/api/schemas.py +4 -0
- claude_mpm/commander/chat/cli.py +47 -5
- claude_mpm/commander/chat/commands.py +44 -16
- claude_mpm/commander/chat/repl.py +1729 -82
- claude_mpm/commander/config.py +5 -3
- claude_mpm/commander/core/__init__.py +10 -0
- claude_mpm/commander/core/block_manager.py +325 -0
- claude_mpm/commander/core/response_manager.py +323 -0
- claude_mpm/commander/daemon.py +215 -10
- claude_mpm/commander/env_loader.py +59 -0
- claude_mpm/commander/events/manager.py +61 -1
- claude_mpm/commander/frameworks/base.py +91 -1
- claude_mpm/commander/frameworks/mpm.py +9 -14
- claude_mpm/commander/git/__init__.py +5 -0
- claude_mpm/commander/git/worktree_manager.py +212 -0
- claude_mpm/commander/instance_manager.py +546 -15
- claude_mpm/commander/memory/__init__.py +45 -0
- claude_mpm/commander/memory/compression.py +347 -0
- claude_mpm/commander/memory/embeddings.py +230 -0
- claude_mpm/commander/memory/entities.py +310 -0
- claude_mpm/commander/memory/example_usage.py +290 -0
- claude_mpm/commander/memory/integration.py +325 -0
- claude_mpm/commander/memory/search.py +381 -0
- claude_mpm/commander/memory/store.py +657 -0
- claude_mpm/commander/models/events.py +6 -0
- claude_mpm/commander/persistence/state_store.py +95 -1
- claude_mpm/commander/registry.py +10 -4
- claude_mpm/commander/runtime/monitor.py +32 -2
- claude_mpm/commander/tmux_orchestrator.py +3 -2
- claude_mpm/commander/work/executor.py +38 -20
- claude_mpm/commander/workflow/event_handler.py +25 -3
- claude_mpm/config/skill_sources.py +16 -0
- claude_mpm/constants.py +5 -0
- claude_mpm/core/claude_runner.py +152 -0
- claude_mpm/core/config.py +30 -22
- claude_mpm/core/config_constants.py +74 -9
- claude_mpm/core/constants.py +56 -12
- claude_mpm/core/hook_manager.py +2 -1
- claude_mpm/core/interactive_session.py +5 -4
- claude_mpm/core/logger.py +16 -2
- claude_mpm/core/logging_utils.py +40 -16
- claude_mpm/core/network_config.py +148 -0
- claude_mpm/core/oneshot_session.py +7 -6
- claude_mpm/core/output_style_manager.py +37 -7
- claude_mpm/core/socketio_pool.py +47 -15
- claude_mpm/core/unified_paths.py +68 -80
- claude_mpm/hooks/claude_hooks/auto_pause_handler.py +30 -31
- claude_mpm/hooks/claude_hooks/event_handlers.py +285 -194
- claude_mpm/hooks/claude_hooks/hook_handler.py +115 -32
- claude_mpm/hooks/claude_hooks/installer.py +222 -54
- claude_mpm/hooks/claude_hooks/memory_integration.py +52 -32
- claude_mpm/hooks/claude_hooks/response_tracking.py +40 -59
- claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
- claude_mpm/hooks/claude_hooks/services/connection_manager.py +25 -30
- claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +24 -28
- claude_mpm/hooks/claude_hooks/services/container.py +326 -0
- claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
- claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
- claude_mpm/hooks/claude_hooks/services/subagent_processor.py +49 -75
- claude_mpm/hooks/session_resume_hook.py +22 -18
- claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
- claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
- claude_mpm/init.py +21 -14
- claude_mpm/mcp/__init__.py +9 -0
- claude_mpm/mcp/google_workspace_server.py +610 -0
- claude_mpm/scripts/claude-hook-handler.sh +10 -9
- claude_mpm/services/agents/agent_selection_service.py +2 -2
- claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
- claude_mpm/services/command_deployment_service.py +44 -26
- claude_mpm/services/hook_installer_service.py +77 -8
- claude_mpm/services/mcp_config_manager.py +99 -19
- claude_mpm/services/mcp_service_registry.py +294 -0
- claude_mpm/services/monitor/server.py +6 -1
- claude_mpm/services/pm_skills_deployer.py +5 -3
- claude_mpm/services/skills/git_skill_source_manager.py +79 -8
- claude_mpm/services/skills/selective_skill_deployer.py +28 -0
- claude_mpm/services/skills/skill_discovery_service.py +17 -1
- claude_mpm/services/skills_deployer.py +31 -5
- claude_mpm/skills/__init__.py +2 -1
- claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
- claude_mpm/skills/registry.py +295 -90
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/METADATA +28 -3
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/RECORD +131 -93
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/WHEEL +1 -1
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/entry_points.txt +2 -0
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Secure OAuth token storage with Fernet encryption and keyring.
|
|
2
|
+
|
|
3
|
+
This module provides encrypted persistence for OAuth tokens, using
|
|
4
|
+
the system keyring for encryption key storage and Fernet symmetric
|
|
5
|
+
encryption for the actual token data.
|
|
6
|
+
|
|
7
|
+
Security Features:
|
|
8
|
+
- Encryption keys stored in system keyring (not on disk)
|
|
9
|
+
- Fernet symmetric encryption for token data
|
|
10
|
+
- File permissions restricted to owner only (600)
|
|
11
|
+
- Credentials stored in user-specific directory
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import stat
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import keyring
|
|
20
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
21
|
+
|
|
22
|
+
from claude_mpm.auth.models import OAuthToken, StoredToken, TokenMetadata, TokenStatus
|
|
23
|
+
|
|
24
|
+
# Keyring service identifier for encryption keys
|
|
25
|
+
KEYRING_SERVICE = "claude-mpm-oauth"
|
|
26
|
+
|
|
27
|
+
# Default credentials directory
|
|
28
|
+
CREDENTIALS_DIR = Path.home() / ".claude-mpm" / "credentials"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TokenStorage:
|
|
32
|
+
"""Secure storage for OAuth tokens using Fernet encryption.
|
|
33
|
+
|
|
34
|
+
Tokens are encrypted using Fernet symmetric encryption with keys
|
|
35
|
+
stored securely in the system keyring. Token files are stored
|
|
36
|
+
with restricted permissions (600) in the credentials directory.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
credentials_dir: Directory where encrypted tokens are stored.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
```python
|
|
43
|
+
storage = TokenStorage()
|
|
44
|
+
|
|
45
|
+
# Store a token
|
|
46
|
+
token = OAuthToken(
|
|
47
|
+
access_token="abc123",
|
|
48
|
+
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
49
|
+
scopes=["read", "write"]
|
|
50
|
+
)
|
|
51
|
+
metadata = TokenMetadata(service_name="github-mcp", provider="github")
|
|
52
|
+
storage.store("github-mcp", token, metadata)
|
|
53
|
+
|
|
54
|
+
# Retrieve the token
|
|
55
|
+
stored = storage.retrieve("github-mcp")
|
|
56
|
+
if stored:
|
|
57
|
+
print(f"Token expires at: {stored.token.expires_at}")
|
|
58
|
+
```
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, credentials_dir: Optional[Path] = None) -> None:
|
|
62
|
+
"""Initialize token storage.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
credentials_dir: Custom directory for storing encrypted tokens.
|
|
66
|
+
Defaults to ~/.claude-mpm/credentials/
|
|
67
|
+
"""
|
|
68
|
+
self.credentials_dir = credentials_dir or CREDENTIALS_DIR
|
|
69
|
+
self._ensure_credentials_dir()
|
|
70
|
+
|
|
71
|
+
def _ensure_credentials_dir(self) -> None:
|
|
72
|
+
"""Create credentials directory with secure permissions if needed."""
|
|
73
|
+
if not self.credentials_dir.exists():
|
|
74
|
+
self.credentials_dir.mkdir(parents=True, mode=0o700)
|
|
75
|
+
else:
|
|
76
|
+
# Ensure directory has correct permissions
|
|
77
|
+
self.credentials_dir.chmod(0o700)
|
|
78
|
+
|
|
79
|
+
def _get_encryption_key(self, service_name: str) -> bytes:
|
|
80
|
+
"""Get or create the Fernet encryption key for a service.
|
|
81
|
+
|
|
82
|
+
Keys are stored in the system keyring, not on disk.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
service_name: Name of the service to get/create key for.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Fernet encryption key as bytes.
|
|
89
|
+
"""
|
|
90
|
+
key_name = f"{service_name}-key"
|
|
91
|
+
existing_key = keyring.get_password(KEYRING_SERVICE, key_name)
|
|
92
|
+
|
|
93
|
+
if existing_key:
|
|
94
|
+
return existing_key.encode()
|
|
95
|
+
|
|
96
|
+
# Generate new key and store in keyring
|
|
97
|
+
new_key = Fernet.generate_key()
|
|
98
|
+
keyring.set_password(KEYRING_SERVICE, key_name, new_key.decode())
|
|
99
|
+
return new_key
|
|
100
|
+
|
|
101
|
+
def _delete_encryption_key(self, service_name: str) -> None:
|
|
102
|
+
"""Delete the encryption key for a service from keyring.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
service_name: Name of the service to delete key for.
|
|
106
|
+
"""
|
|
107
|
+
key_name = f"{service_name}-key"
|
|
108
|
+
try:
|
|
109
|
+
keyring.delete_password(KEYRING_SERVICE, key_name)
|
|
110
|
+
except keyring.errors.PasswordDeleteError:
|
|
111
|
+
pass # Key doesn't exist, nothing to delete
|
|
112
|
+
|
|
113
|
+
def _get_token_path(self, service_name: str) -> Path:
|
|
114
|
+
"""Get the file path for a service's encrypted token.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
service_name: Name of the service.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Path to the encrypted token file.
|
|
121
|
+
"""
|
|
122
|
+
# Sanitize service name for filesystem
|
|
123
|
+
safe_name = "".join(
|
|
124
|
+
c if c.isalnum() or c in "-_" else "_" for c in service_name
|
|
125
|
+
)
|
|
126
|
+
return self.credentials_dir / f"{safe_name}.enc"
|
|
127
|
+
|
|
128
|
+
def store(
|
|
129
|
+
self,
|
|
130
|
+
service_name: str,
|
|
131
|
+
token: OAuthToken,
|
|
132
|
+
metadata: TokenMetadata,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Store an OAuth token securely.
|
|
135
|
+
|
|
136
|
+
The token is encrypted using Fernet symmetric encryption with
|
|
137
|
+
a key stored in the system keyring. The encrypted data is
|
|
138
|
+
written to a file with 600 permissions.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
service_name: Unique identifier for the service.
|
|
142
|
+
token: OAuth token data to store.
|
|
143
|
+
metadata: Token metadata including provider info.
|
|
144
|
+
"""
|
|
145
|
+
stored_token = StoredToken(
|
|
146
|
+
version=1,
|
|
147
|
+
metadata=metadata,
|
|
148
|
+
token=token,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Serialize to JSON
|
|
152
|
+
token_json = stored_token.model_dump_json()
|
|
153
|
+
|
|
154
|
+
# Encrypt the token data
|
|
155
|
+
key = self._get_encryption_key(service_name)
|
|
156
|
+
fernet = Fernet(key)
|
|
157
|
+
encrypted_data = fernet.encrypt(token_json.encode())
|
|
158
|
+
|
|
159
|
+
# Write encrypted data with secure permissions
|
|
160
|
+
token_path = self._get_token_path(service_name)
|
|
161
|
+
token_path.write_bytes(encrypted_data)
|
|
162
|
+
|
|
163
|
+
# Set file permissions to owner read/write only (600)
|
|
164
|
+
token_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
165
|
+
|
|
166
|
+
def retrieve(self, service_name: str) -> Optional[StoredToken]:
|
|
167
|
+
"""Retrieve a stored OAuth token.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
service_name: Unique identifier for the service.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
StoredToken if found and valid, None otherwise.
|
|
174
|
+
"""
|
|
175
|
+
token_path = self._get_token_path(service_name)
|
|
176
|
+
|
|
177
|
+
if not token_path.exists():
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Read encrypted data
|
|
182
|
+
encrypted_data = token_path.read_bytes()
|
|
183
|
+
|
|
184
|
+
# Decrypt
|
|
185
|
+
key = self._get_encryption_key(service_name)
|
|
186
|
+
fernet = Fernet(key)
|
|
187
|
+
decrypted_data = fernet.decrypt(encrypted_data)
|
|
188
|
+
|
|
189
|
+
# Deserialize
|
|
190
|
+
return StoredToken.model_validate_json(decrypted_data)
|
|
191
|
+
|
|
192
|
+
except (InvalidToken, json.JSONDecodeError, ValueError):
|
|
193
|
+
# Token is corrupted or invalid
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def delete(self, service_name: str) -> bool:
|
|
197
|
+
"""Delete a stored token and its encryption key.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
service_name: Unique identifier for the service.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
True if token was deleted, False if it didn't exist.
|
|
204
|
+
"""
|
|
205
|
+
token_path = self._get_token_path(service_name)
|
|
206
|
+
|
|
207
|
+
if not token_path.exists():
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
# Delete the token file
|
|
211
|
+
token_path.unlink()
|
|
212
|
+
|
|
213
|
+
# Delete the encryption key from keyring
|
|
214
|
+
self._delete_encryption_key(service_name)
|
|
215
|
+
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def list_services(self) -> list[str]:
|
|
219
|
+
"""List all services with stored tokens.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
List of service names that have stored tokens.
|
|
223
|
+
"""
|
|
224
|
+
services = []
|
|
225
|
+
for path in self.credentials_dir.glob("*.enc"):
|
|
226
|
+
# Extract service name from filename
|
|
227
|
+
service_name = path.stem
|
|
228
|
+
services.append(service_name)
|
|
229
|
+
return sorted(services)
|
|
230
|
+
|
|
231
|
+
def get_status(self, service_name: str) -> TokenStatus:
|
|
232
|
+
"""Get the status of a stored token.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
service_name: Unique identifier for the service.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
TokenStatus indicating the token's current state.
|
|
239
|
+
"""
|
|
240
|
+
stored = self.retrieve(service_name)
|
|
241
|
+
|
|
242
|
+
if stored is None:
|
|
243
|
+
token_path = self._get_token_path(service_name)
|
|
244
|
+
if token_path.exists():
|
|
245
|
+
# File exists but couldn't be decrypted
|
|
246
|
+
return TokenStatus.INVALID
|
|
247
|
+
return TokenStatus.MISSING
|
|
248
|
+
|
|
249
|
+
if stored.token.is_expired():
|
|
250
|
+
return TokenStatus.EXPIRED
|
|
251
|
+
|
|
252
|
+
return TokenStatus.VALID
|
|
@@ -2,25 +2,129 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
5
9
|
|
|
6
10
|
logger = logging.getLogger(__name__)
|
|
7
11
|
|
|
12
|
+
# ANSI colors
|
|
13
|
+
CYAN = "\033[36m"
|
|
14
|
+
DIM = "\033[2m"
|
|
15
|
+
BOLD = "\033[1m"
|
|
16
|
+
YELLOW = "\033[33m"
|
|
17
|
+
GREEN = "\033[32m"
|
|
18
|
+
RED = "\033[31m"
|
|
19
|
+
RESET = "\033[0m"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_terminal_width() -> int:
|
|
23
|
+
"""Get terminal width with reasonable bounds."""
|
|
24
|
+
try:
|
|
25
|
+
width = shutil.get_terminal_size().columns
|
|
26
|
+
return max(80, min(width, 120))
|
|
27
|
+
except Exception:
|
|
28
|
+
return 100
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_version() -> str:
|
|
32
|
+
"""Get Commander version."""
|
|
33
|
+
version_file = Path(__file__).parent.parent.parent / "VERSION"
|
|
34
|
+
if version_file.exists():
|
|
35
|
+
return version_file.read_text().strip()
|
|
36
|
+
return "unknown"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def display_commander_banner():
|
|
40
|
+
"""Display Commander-specific startup banner."""
|
|
41
|
+
width = _get_terminal_width()
|
|
42
|
+
version = _get_version()
|
|
43
|
+
|
|
44
|
+
# Commander ASCII art banner
|
|
45
|
+
banner = f"""
|
|
46
|
+
{CYAN}╭{"─" * (width - 2)}╮{RESET}
|
|
47
|
+
{CYAN}│{RESET}{BOLD} ⚡ MPM Commander {RESET}{DIM}v{version}{RESET}{" " * (width - 24 - len(version))}│
|
|
48
|
+
{CYAN}│{RESET}{DIM} Multi-Project AI Orchestration{RESET}{" " * (width - 36)}│
|
|
49
|
+
{CYAN}├{"─" * (width - 2)}┤{RESET}
|
|
50
|
+
{CYAN}│{RESET} {YELLOW}ALPHA{RESET} - APIs may change {" " * (width - 55)}│
|
|
51
|
+
{CYAN}╰{"─" * (width - 2)}╯{RESET}
|
|
52
|
+
"""
|
|
53
|
+
print(banner)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _count_cached_agents() -> int:
|
|
57
|
+
"""Count cached agents from ~/.claude-mpm/cache/agents/."""
|
|
58
|
+
try:
|
|
59
|
+
cache_agents_dir = Path.home() / ".claude-mpm" / "cache" / "agents"
|
|
60
|
+
if not cache_agents_dir.exists():
|
|
61
|
+
return 0
|
|
62
|
+
# Recursively find all .md files excluding base/README files
|
|
63
|
+
agent_files = [
|
|
64
|
+
f
|
|
65
|
+
for f in cache_agents_dir.rglob("*.md")
|
|
66
|
+
if f.is_file()
|
|
67
|
+
and not f.name.startswith(".")
|
|
68
|
+
and f.name not in ("README.md", "BASE-AGENT.md", "INSTRUCTIONS.md")
|
|
69
|
+
]
|
|
70
|
+
return len(agent_files)
|
|
71
|
+
except Exception:
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _count_cached_skills() -> int:
|
|
76
|
+
"""Count cached skills from ~/.claude-mpm/cache/skills/."""
|
|
77
|
+
try:
|
|
78
|
+
cache_skills_dir = Path.home() / ".claude-mpm" / "cache" / "skills"
|
|
79
|
+
if not cache_skills_dir.exists():
|
|
80
|
+
return 0
|
|
81
|
+
# Recursively find all directories containing SKILL.md
|
|
82
|
+
skill_files = list(cache_skills_dir.rglob("SKILL.md"))
|
|
83
|
+
return len(skill_files)
|
|
84
|
+
except Exception:
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_agents_and_skills():
|
|
89
|
+
"""Load agents and skills for Commander sessions."""
|
|
90
|
+
try:
|
|
91
|
+
print(f"{DIM}Loading agents...{RESET}", end=" ", flush=True)
|
|
92
|
+
agent_count = _count_cached_agents()
|
|
93
|
+
print(f"{GREEN}✓{RESET} {agent_count} agents")
|
|
94
|
+
|
|
95
|
+
print(f"{DIM}Loading skills...{RESET}", end=" ", flush=True)
|
|
96
|
+
skill_count = _count_cached_skills()
|
|
97
|
+
print(f"{GREEN}✓{RESET} {skill_count} skills")
|
|
98
|
+
|
|
99
|
+
return agent_count, skill_count
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.warning(f"Could not load agents/skills: {e}")
|
|
102
|
+
print(f"{YELLOW}⚠{RESET} Could not load agents/skills")
|
|
103
|
+
return 0, 0
|
|
104
|
+
|
|
8
105
|
|
|
9
106
|
def handle_commander_command(args) -> int:
|
|
10
|
-
"""Handle the commander command.
|
|
107
|
+
"""Handle the commander command with auto-starting daemon.
|
|
11
108
|
|
|
12
109
|
Args:
|
|
13
110
|
args: Parsed command line arguments with:
|
|
14
|
-
- port: Port for
|
|
111
|
+
- port: Port for daemon (default: 8765)
|
|
112
|
+
- host: Host for daemon (default: 127.0.0.1)
|
|
15
113
|
- state_dir: Optional state directory path
|
|
16
114
|
- debug: Enable debug logging
|
|
115
|
+
- no_chat: Start daemon only without interactive chat
|
|
116
|
+
- daemon_only: Alias for no_chat
|
|
17
117
|
|
|
18
118
|
Returns:
|
|
19
119
|
Exit code (0 for success, 1 for error)
|
|
20
120
|
"""
|
|
21
121
|
try:
|
|
22
122
|
# Import here to avoid circular dependencies
|
|
123
|
+
import requests
|
|
124
|
+
|
|
23
125
|
from claude_mpm.commander.chat.cli import run_commander
|
|
126
|
+
from claude_mpm.commander.config import DaemonConfig
|
|
127
|
+
from claude_mpm.commander.daemon import main as daemon_main
|
|
24
128
|
|
|
25
129
|
# Setup debug logging if requested
|
|
26
130
|
if getattr(args, "debug", False):
|
|
@@ -29,11 +133,76 @@ def handle_commander_command(args) -> int:
|
|
|
29
133
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
30
134
|
)
|
|
31
135
|
|
|
136
|
+
# Display Commander banner
|
|
137
|
+
display_commander_banner()
|
|
138
|
+
|
|
139
|
+
# Load agents and skills
|
|
140
|
+
load_agents_and_skills()
|
|
141
|
+
|
|
142
|
+
print() # Blank line after loading
|
|
143
|
+
|
|
32
144
|
# Get arguments
|
|
33
|
-
port = getattr(args, "port",
|
|
145
|
+
port = getattr(args, "port", 8766) # NetworkPorts.COMMANDER_DEFAULT
|
|
146
|
+
host = getattr(args, "host", "127.0.0.1")
|
|
34
147
|
state_dir = getattr(args, "state_dir", None)
|
|
148
|
+
no_chat = getattr(args, "no_chat", False) or getattr(args, "daemon_only", False)
|
|
149
|
+
|
|
150
|
+
# Check if daemon already running
|
|
151
|
+
daemon_running = False
|
|
152
|
+
try:
|
|
153
|
+
resp = requests.get(f"http://{host}:{port}/api/health", timeout=1)
|
|
154
|
+
if resp.status_code == 200:
|
|
155
|
+
print(f"{GREEN}✓{RESET} Daemon already running on {host}:{port}")
|
|
156
|
+
daemon_running = True
|
|
157
|
+
except (requests.RequestException, requests.ConnectionError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Start daemon if not running
|
|
161
|
+
if not daemon_running:
|
|
162
|
+
print(
|
|
163
|
+
f"{DIM}Starting daemon on {host}:{port}...{RESET}", end=" ", flush=True
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Create daemon config
|
|
167
|
+
config_kwargs = {"host": host, "port": port}
|
|
168
|
+
if state_dir:
|
|
169
|
+
config_kwargs["state_dir"] = state_dir
|
|
170
|
+
config = DaemonConfig(**config_kwargs)
|
|
171
|
+
|
|
172
|
+
# Start daemon in background thread
|
|
173
|
+
daemon_thread = threading.Thread(
|
|
174
|
+
target=lambda: asyncio.run(daemon_main(config)), daemon=True
|
|
175
|
+
)
|
|
176
|
+
daemon_thread.start()
|
|
177
|
+
|
|
178
|
+
# Wait for daemon to be ready (max 3 seconds)
|
|
179
|
+
for _ in range(30):
|
|
180
|
+
time.sleep(0.1)
|
|
181
|
+
try:
|
|
182
|
+
resp = requests.get(f"http://{host}:{port}/api/health", timeout=1)
|
|
183
|
+
if resp.status_code == 200:
|
|
184
|
+
print(f"{GREEN}✓{RESET}")
|
|
185
|
+
daemon_running = True
|
|
186
|
+
break
|
|
187
|
+
except (requests.RequestException, requests.ConnectionError):
|
|
188
|
+
pass
|
|
189
|
+
else:
|
|
190
|
+
print(f"{RED}✗{RESET} Failed (timeout)")
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
# If daemon-only mode, keep running until interrupted
|
|
194
|
+
if no_chat:
|
|
195
|
+
print(f"\n{CYAN}Daemon running.{RESET} API at http://{host}:{port}")
|
|
196
|
+
print(f"{DIM}Press Ctrl+C to stop{RESET}\n")
|
|
197
|
+
try:
|
|
198
|
+
while True:
|
|
199
|
+
time.sleep(1)
|
|
200
|
+
except KeyboardInterrupt:
|
|
201
|
+
print(f"\n{DIM}Shutting down...{RESET}")
|
|
202
|
+
return 0
|
|
35
203
|
|
|
36
|
-
#
|
|
204
|
+
# Launch interactive chat
|
|
205
|
+
print(f"\n{CYAN}Entering Commander chat...{RESET}\n")
|
|
37
206
|
asyncio.run(run_commander(port=port, state_dir=state_dir))
|
|
38
207
|
|
|
39
208
|
return 0
|
|
@@ -43,4 +212,5 @@ def handle_commander_command(args) -> int:
|
|
|
43
212
|
return 0
|
|
44
213
|
except Exception as e:
|
|
45
214
|
logger.error(f"Commander error: {e}", exc_info=True)
|
|
215
|
+
print(f"{RED}Error:{RESET} {e}")
|
|
46
216
|
return 1
|
claude_mpm/cli/commands/mcp.py
CHANGED
|
@@ -33,7 +33,27 @@ def manage_mcp(args):
|
|
|
33
33
|
"""
|
|
34
34
|
logger = get_logger("cli.mcp")
|
|
35
35
|
|
|
36
|
-
#
|
|
36
|
+
# Commands that don't require full MCP Gateway or mcp package
|
|
37
|
+
# These only need mcp_service_registry which doesn't require the mcp package
|
|
38
|
+
service_mgmt_commands = {
|
|
39
|
+
MCPCommands.ENABLE.value,
|
|
40
|
+
MCPCommands.DISABLE.value,
|
|
41
|
+
MCPCommands.LIST.value,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Route service management commands directly without any MCP dependencies
|
|
45
|
+
if args.mcp_command in service_mgmt_commands:
|
|
46
|
+
try:
|
|
47
|
+
from .mcp_command_router import MCPCommandRouter
|
|
48
|
+
|
|
49
|
+
router = MCPCommandRouter(logger)
|
|
50
|
+
return router.route_command(args)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Error running service command: {e}", exc_info=True)
|
|
53
|
+
print(f"Error: {e}")
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
# Now check for mcp package for other commands
|
|
37
57
|
import importlib.util
|
|
38
58
|
|
|
39
59
|
mcp_spec = importlib.util.find_spec("mcp")
|
|
@@ -58,22 +78,14 @@ def manage_mcp(args):
|
|
|
58
78
|
except ImportError as e:
|
|
59
79
|
# Provide minimal fallbacks for basic commands
|
|
60
80
|
logger.warning(f"Some MCP Gateway services not available: {e}")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
print(
|
|
70
|
-
"\nError: MCP Gateway services not fully available",
|
|
71
|
-
file=sys.stderr,
|
|
72
|
-
)
|
|
73
|
-
print(f"Details: {e}", file=sys.stderr)
|
|
74
|
-
print("\nTry running:", file=sys.stderr)
|
|
75
|
-
print(" claude-mpm mcp install", file=sys.stderr)
|
|
76
|
-
return 1
|
|
81
|
+
print(
|
|
82
|
+
"\nError: MCP Gateway services not fully available",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
print(f"Details: {e}", file=sys.stderr)
|
|
86
|
+
print("\nTry running:", file=sys.stderr)
|
|
87
|
+
print(" claude-mpm mcp install", file=sys.stderr)
|
|
88
|
+
return 1
|
|
77
89
|
|
|
78
90
|
if not args.mcp_command:
|
|
79
91
|
# No subcommand - show status by default
|
|
@@ -48,6 +48,15 @@ class MCPCommandRouter:
|
|
|
48
48
|
if args.mcp_command == MCPCommands.EXTERNAL.value:
|
|
49
49
|
return self._manage_external(args)
|
|
50
50
|
|
|
51
|
+
if args.mcp_command == MCPCommands.ENABLE.value:
|
|
52
|
+
return self._enable_service(args)
|
|
53
|
+
|
|
54
|
+
if args.mcp_command == MCPCommands.DISABLE.value:
|
|
55
|
+
return self._disable_service(args)
|
|
56
|
+
|
|
57
|
+
if args.mcp_command == MCPCommands.LIST.value:
|
|
58
|
+
return self._list_services(args)
|
|
59
|
+
|
|
51
60
|
if args.mcp_command == "cleanup":
|
|
52
61
|
return self._cleanup_locks(args)
|
|
53
62
|
|
|
@@ -134,6 +143,27 @@ class MCPCommandRouter:
|
|
|
134
143
|
handler = MCPExternalCommands(self.logger)
|
|
135
144
|
return handler.manage_external(args)
|
|
136
145
|
|
|
146
|
+
def _enable_service(self, args) -> int:
|
|
147
|
+
"""Enable MCP service command handler."""
|
|
148
|
+
from .mcp_service_commands import MCPServiceCommands
|
|
149
|
+
|
|
150
|
+
handler = MCPServiceCommands(self.logger)
|
|
151
|
+
return handler.enable_service(args)
|
|
152
|
+
|
|
153
|
+
def _disable_service(self, args) -> int:
|
|
154
|
+
"""Disable MCP service command handler."""
|
|
155
|
+
from .mcp_service_commands import MCPServiceCommands
|
|
156
|
+
|
|
157
|
+
handler = MCPServiceCommands(self.logger)
|
|
158
|
+
return handler.disable_service(args)
|
|
159
|
+
|
|
160
|
+
def _list_services(self, args) -> int:
|
|
161
|
+
"""List MCP services command handler."""
|
|
162
|
+
from .mcp_service_commands import MCPServiceCommands
|
|
163
|
+
|
|
164
|
+
handler = MCPServiceCommands(self.logger)
|
|
165
|
+
return handler.list_services(args)
|
|
166
|
+
|
|
137
167
|
def _show_help(self):
|
|
138
168
|
"""Show available MCP commands."""
|
|
139
169
|
print("\nAvailable MCP commands:")
|
|
@@ -148,6 +178,10 @@ class MCPCommandRouter:
|
|
|
148
178
|
print(" config - View and manage configuration")
|
|
149
179
|
print(" external - Manage external MCP services")
|
|
150
180
|
print(" cleanup - Clean up legacy files")
|
|
181
|
+
print("\nService management:")
|
|
182
|
+
print(" enable - Enable an MCP service in configuration")
|
|
183
|
+
print(" disable - Disable an MCP service from configuration")
|
|
184
|
+
print(" list - List available and enabled MCP services")
|
|
151
185
|
print("\nFor help with a specific command:")
|
|
152
186
|
print(" claude-mpm mcp <command> --help")
|
|
153
187
|
print("\nExamples:")
|
|
@@ -159,3 +193,8 @@ class MCPCommandRouter:
|
|
|
159
193
|
print(" claude-mpm mcp tools")
|
|
160
194
|
print(" claude-mpm mcp register my-tool")
|
|
161
195
|
print(" claude-mpm mcp test my-tool")
|
|
196
|
+
print("\nService management examples:")
|
|
197
|
+
print(" claude-mpm mcp list --available # List all available services")
|
|
198
|
+
print(" claude-mpm mcp enable kuzu-memory # Enable a service")
|
|
199
|
+
print(" claude-mpm mcp enable mcp-github --interactive # Enable with prompts")
|
|
200
|
+
print(" claude-mpm mcp disable mcp-github # Disable a service")
|