claude-mpm 4.3.22__py3-none-any.whl → 4.4.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 (74) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/WORKFLOW.md +2 -14
  3. claude_mpm/cli/commands/configure.py +2 -29
  4. claude_mpm/cli/commands/doctor.py +2 -2
  5. claude_mpm/cli/commands/mpm_init.py +3 -3
  6. claude_mpm/cli/parsers/configure_parser.py +4 -15
  7. claude_mpm/core/framework/__init__.py +38 -0
  8. claude_mpm/core/framework/formatters/__init__.py +11 -0
  9. claude_mpm/core/framework/formatters/capability_generator.py +356 -0
  10. claude_mpm/core/framework/formatters/content_formatter.py +283 -0
  11. claude_mpm/core/framework/formatters/context_generator.py +180 -0
  12. claude_mpm/core/framework/loaders/__init__.py +13 -0
  13. claude_mpm/core/framework/loaders/agent_loader.py +202 -0
  14. claude_mpm/core/framework/loaders/file_loader.py +213 -0
  15. claude_mpm/core/framework/loaders/instruction_loader.py +151 -0
  16. claude_mpm/core/framework/loaders/packaged_loader.py +208 -0
  17. claude_mpm/core/framework/processors/__init__.py +11 -0
  18. claude_mpm/core/framework/processors/memory_processor.py +222 -0
  19. claude_mpm/core/framework/processors/metadata_processor.py +146 -0
  20. claude_mpm/core/framework/processors/template_processor.py +238 -0
  21. claude_mpm/core/framework_loader.py +277 -1798
  22. claude_mpm/hooks/__init__.py +9 -1
  23. claude_mpm/hooks/kuzu_memory_hook.py +352 -0
  24. claude_mpm/hooks/memory_integration_hook.py +1 -1
  25. claude_mpm/services/agents/memory/content_manager.py +5 -2
  26. claude_mpm/services/agents/memory/memory_file_service.py +1 -0
  27. claude_mpm/services/agents/memory/memory_limits_service.py +1 -0
  28. claude_mpm/services/core/path_resolver.py +1 -0
  29. claude_mpm/services/diagnostics/diagnostic_runner.py +1 -0
  30. claude_mpm/services/mcp_config_manager.py +67 -4
  31. claude_mpm/services/mcp_gateway/core/process_pool.py +281 -0
  32. claude_mpm/services/mcp_gateway/core/startup_verification.py +2 -2
  33. claude_mpm/services/mcp_gateway/main.py +3 -13
  34. claude_mpm/services/mcp_gateway/server/stdio_server.py +4 -10
  35. claude_mpm/services/mcp_gateway/tools/__init__.py +13 -2
  36. claude_mpm/services/mcp_gateway/tools/external_mcp_services.py +36 -6
  37. claude_mpm/services/mcp_gateway/tools/kuzu_memory_service.py +542 -0
  38. claude_mpm/services/shared/__init__.py +2 -1
  39. claude_mpm/services/shared/service_factory.py +8 -5
  40. claude_mpm/services/unified/__init__.py +65 -0
  41. claude_mpm/services/unified/analyzer_strategies/__init__.py +44 -0
  42. claude_mpm/services/unified/analyzer_strategies/code_analyzer.py +473 -0
  43. claude_mpm/services/unified/analyzer_strategies/dependency_analyzer.py +643 -0
  44. claude_mpm/services/unified/analyzer_strategies/performance_analyzer.py +804 -0
  45. claude_mpm/services/unified/analyzer_strategies/security_analyzer.py +661 -0
  46. claude_mpm/services/unified/analyzer_strategies/structure_analyzer.py +696 -0
  47. claude_mpm/services/unified/config_strategies/__init__.py +190 -0
  48. claude_mpm/services/unified/config_strategies/config_schema.py +689 -0
  49. claude_mpm/services/unified/config_strategies/context_strategy.py +748 -0
  50. claude_mpm/services/unified/config_strategies/error_handling_strategy.py +999 -0
  51. claude_mpm/services/unified/config_strategies/file_loader_strategy.py +871 -0
  52. claude_mpm/services/unified/config_strategies/unified_config_service.py +802 -0
  53. claude_mpm/services/unified/config_strategies/validation_strategy.py +1105 -0
  54. claude_mpm/services/unified/deployment_strategies/__init__.py +97 -0
  55. claude_mpm/services/unified/deployment_strategies/base.py +557 -0
  56. claude_mpm/services/unified/deployment_strategies/cloud_strategies.py +486 -0
  57. claude_mpm/services/unified/deployment_strategies/local.py +594 -0
  58. claude_mpm/services/unified/deployment_strategies/utils.py +672 -0
  59. claude_mpm/services/unified/deployment_strategies/vercel.py +471 -0
  60. claude_mpm/services/unified/interfaces.py +499 -0
  61. claude_mpm/services/unified/migration.py +532 -0
  62. claude_mpm/services/unified/strategies.py +551 -0
  63. claude_mpm/services/unified/unified_analyzer.py +534 -0
  64. claude_mpm/services/unified/unified_config.py +688 -0
  65. claude_mpm/services/unified/unified_deployment.py +470 -0
  66. {claude_mpm-4.3.22.dist-info → claude_mpm-4.4.3.dist-info}/METADATA +15 -15
  67. {claude_mpm-4.3.22.dist-info → claude_mpm-4.4.3.dist-info}/RECORD +71 -32
  68. claude_mpm/cli/commands/configure_tui.py +0 -1927
  69. claude_mpm/services/mcp_gateway/tools/ticket_tools.py +0 -645
  70. claude_mpm/services/mcp_gateway/tools/unified_ticket_tool.py +0 -602
  71. {claude_mpm-4.3.22.dist-info → claude_mpm-4.4.3.dist-info}/WHEEL +0 -0
  72. {claude_mpm-4.3.22.dist-info → claude_mpm-4.4.3.dist-info}/entry_points.txt +0 -0
  73. {claude_mpm-4.3.22.dist-info → claude_mpm-4.4.3.dist-info}/licenses/LICENSE +0 -0
  74. {claude_mpm-4.3.22.dist-info → claude_mpm-4.4.3.dist-info}/top_level.txt +0 -0
@@ -1,1927 +0,0 @@
1
- """
2
- Full-screen terminal interface for claude-mpm configuration using Textual.
3
-
4
- WHY: Provides a modern, user-friendly TUI for managing configurations with
5
- keyboard navigation, mouse support, and responsive layouts.
6
-
7
- DESIGN DECISIONS:
8
- - Use Textual for modern full-screen terminal interface
9
- - Implement multiple screens with sidebar navigation
10
- - Support both keyboard and mouse interaction
11
- - Provide live search and filtering capabilities
12
- - Use modal dialogs for confirmations
13
- - Maintain consistency with existing configuration logic
14
-
15
- EVENT HANDLING FIX:
16
- - ListView selection events are handled via on_list_view_selected method
17
- - Multiple event handlers provide fallback for different interaction methods
18
- - Enter key binding (action_select_nav) handles keyboard selection
19
- - Debug logging helps diagnose event flow issues
20
- - Index-based selection is most reliable for screen switching
21
- """
22
-
23
- import json
24
- import os
25
- import sys
26
- from datetime import datetime, timezone
27
- from pathlib import Path
28
- from typing import Any, Dict, List, Optional
29
-
30
- from textual import on, work
31
- from textual.app import App, ComposeResult
32
- from textual.binding import Binding
33
- from textual.containers import Container, Horizontal, Vertical
34
- from textual.screen import ModalScreen
35
- from textual.widgets import (
36
- Button,
37
- ContentSwitcher,
38
- DataTable,
39
- Footer,
40
- Header,
41
- Input,
42
- Label,
43
- ListItem,
44
- ListView,
45
- Rule,
46
- Static,
47
- TabbedContent,
48
- TabPane,
49
- TextArea,
50
- Tree,
51
- )
52
-
53
- from ...services.version_service import VersionService
54
- from ..shared import CommandResult
55
-
56
-
57
- class AgentConfig:
58
- """Agent configuration model matching the existing implementation."""
59
-
60
- def __init__(
61
- self, name: str, description: str = "", dependencies: Optional[List[str]] = None
62
- ):
63
- self.name = name
64
- self.description = description
65
- self.dependencies = dependencies or []
66
- self.enabled = True
67
-
68
-
69
- class SimpleAgentManager:
70
- """Agent manager matching the existing implementation."""
71
-
72
- def __init__(self, config_dir: Path):
73
- self.config_dir = config_dir
74
- self.config_file = config_dir / "agent_states.json"
75
- self.config_dir.mkdir(parents=True, exist_ok=True)
76
- self._load_states()
77
- self.templates_dir = (
78
- Path(__file__).parent.parent.parent / "agents" / "templates"
79
- )
80
-
81
- def _load_states(self):
82
- """Load agent states from file."""
83
- if self.config_file.exists():
84
- with open(self.config_file) as f:
85
- self.states = json.load(f)
86
- else:
87
- self.states = {}
88
-
89
- def _save_states(self):
90
- """Save agent states to file."""
91
- with open(self.config_file, "w") as f:
92
- json.dump(self.states, f, indent=2)
93
-
94
- def is_agent_enabled(self, agent_name: str) -> bool:
95
- """Check if an agent is enabled."""
96
- return self.states.get(agent_name, {}).get("enabled", True)
97
-
98
- def set_agent_enabled(self, agent_name: str, enabled: bool):
99
- """Set agent enabled state."""
100
- if agent_name not in self.states:
101
- self.states[agent_name] = {}
102
- self.states[agent_name]["enabled"] = enabled
103
- self._save_states()
104
-
105
- def discover_agents(self) -> List[AgentConfig]:
106
- """Discover available agents from template JSON files."""
107
- agents = []
108
-
109
- if not self.templates_dir.exists():
110
- return [
111
- AgentConfig("engineer", "Engineering agent (templates not found)", []),
112
- AgentConfig("research", "Research agent (templates not found)", []),
113
- ]
114
-
115
- try:
116
- for template_file in sorted(self.templates_dir.glob("*.json")):
117
- if "backup" in template_file.name.lower():
118
- continue
119
-
120
- try:
121
- with open(template_file) as f:
122
- template_data = json.load(f)
123
-
124
- agent_id = template_data.get("agent_id", template_file.stem)
125
- metadata = template_data.get("metadata", {})
126
- metadata.get("name", agent_id)
127
- description = metadata.get(
128
- "description", "No description available"
129
- )
130
-
131
- capabilities = template_data.get("capabilities", {})
132
- tools = capabilities.get("tools", [])
133
- display_tools = tools[:3] if len(tools) > 3 else tools
134
-
135
- normalized_id = agent_id.replace("-agent", "").replace("_", "-")
136
-
137
- agent = AgentConfig(
138
- name=normalized_id,
139
- description=(
140
- description[:80] + "..."
141
- if len(description) > 80
142
- else description
143
- ),
144
- dependencies=display_tools,
145
- )
146
- agent.enabled = self.is_agent_enabled(normalized_id)
147
- agents.append(agent)
148
-
149
- except (json.JSONDecodeError, KeyError):
150
- continue
151
-
152
- except Exception:
153
- return [
154
- AgentConfig("engineer", "Error loading templates", []),
155
- AgentConfig("research", "Research agent", []),
156
- ]
157
-
158
- agents.sort(key=lambda a: a.name)
159
- return agents if agents else [AgentConfig("engineer", "No agents found", [])]
160
-
161
-
162
- class ConfirmDialog(ModalScreen):
163
- """Modal dialog for confirmations."""
164
-
165
- def __init__(self, message: str, title: str = "Confirm"):
166
- super().__init__()
167
- self.message = message
168
- self.title = title
169
-
170
- def compose(self) -> ComposeResult:
171
- with Container(id="confirm-dialog"):
172
- yield Label(self.title, id="confirm-title")
173
- yield Label(self.message, id="confirm-message")
174
- with Horizontal(id="confirm-buttons"):
175
- yield Button("Yes", variant="primary", id="confirm-yes")
176
- yield Button("No", variant="default", id="confirm-no")
177
-
178
- @on(Button.Pressed, "#confirm-yes")
179
- def on_yes(self):
180
- self.dismiss(True)
181
-
182
- @on(Button.Pressed, "#confirm-no")
183
- def on_no(self):
184
- self.dismiss(False)
185
-
186
-
187
- class EditTemplateDialog(ModalScreen):
188
- """Modal dialog for template editing."""
189
-
190
- def __init__(self, agent_name: str, template: Dict[str, Any]):
191
- super().__init__()
192
- self.agent_name = agent_name
193
- self.template = template
194
-
195
- def compose(self) -> ComposeResult:
196
- with Container(id="edit-dialog"):
197
- yield Label(f"Edit Template: {self.agent_name}", id="edit-title")
198
- yield TextArea(json.dumps(self.template, indent=2), id="template-editor")
199
- with Horizontal(id="edit-buttons"):
200
- yield Button("Save", variant="primary", id="save-template")
201
- yield Button("Cancel", variant="default", id="cancel-edit")
202
-
203
- @on(Button.Pressed, "#save-template")
204
- def on_save(self):
205
- editor = self.query_one("#template-editor", TextArea)
206
- try:
207
- template = json.loads(editor.text)
208
- self.dismiss(template)
209
- except json.JSONDecodeError as e:
210
- # Show error in the editor
211
- self.notify(f"Invalid JSON: {e}", severity="error")
212
-
213
- @on(Button.Pressed, "#cancel-edit")
214
- def on_cancel(self):
215
- self.dismiss(None)
216
-
217
-
218
- class AgentInfo:
219
- """Extended agent information with deployment status."""
220
-
221
- def __init__(
222
- self,
223
- name: str,
224
- category: str,
225
- template_path: Path,
226
- description: str = "",
227
- version: str = "1.0.0",
228
- tools: Optional[List[str]] = None,
229
- model: Optional[str] = None,
230
- ):
231
- self.name = name
232
- self.category = category # "system", "project", "user"
233
- self.template_path = template_path
234
- self.description = description
235
- self.version = version
236
- self.tools = tools or []
237
- self.model = model
238
- self.is_deployed = False
239
- self.deployment_path = None
240
- self.metadata = {}
241
- self.last_modified = None
242
-
243
- def check_deployment(self, project_dir: Path):
244
- """Check if this agent is deployed to .claude/agents/."""
245
- claude_agents_dir = project_dir / ".claude" / "agents"
246
- possible_names = [
247
- f"{self.name}.md",
248
- f"{self.name.replace('-', '_')}.md",
249
- f"{self.name}-agent.md",
250
- f"{self.name.replace('-', '_')}_agent.md",
251
- ]
252
-
253
- for name in possible_names:
254
- deployed_file = claude_agents_dir / name
255
- if deployed_file.exists():
256
- self.is_deployed = True
257
- self.deployment_path = deployed_file
258
- return True
259
-
260
- self.is_deployed = False
261
- self.deployment_path = None
262
- return False
263
-
264
-
265
- class AgentDiscovery:
266
- """Service to discover agents from all sources."""
267
-
268
- def __init__(self, project_dir: Path):
269
- self.project_dir = project_dir
270
- # System agents from the package
271
- self.system_templates_dir = (
272
- Path(__file__).parent.parent.parent / "agents" / "templates"
273
- )
274
- # Project agents from .claude-mpm/agents/
275
- self.project_agents_dir = project_dir / ".claude-mpm" / "agents"
276
- # User agents from ~/.claude-mpm/agents/
277
- self.user_agents_dir = Path.home() / ".claude-mpm" / "agents"
278
-
279
- def discover_all_agents(self) -> Dict[str, List[AgentInfo]]:
280
- """Discover agents from all sources, categorized."""
281
- agents = {
282
- "system": self._discover_system_agents(),
283
- "project": self._discover_project_agents(),
284
- "user": self._discover_user_agents(),
285
- }
286
-
287
- # Check deployment status for all agents
288
- for category in agents.values():
289
- for agent in category:
290
- agent.check_deployment(self.project_dir)
291
-
292
- return agents
293
-
294
- def _discover_system_agents(self) -> List[AgentInfo]:
295
- """Discover system agents from package templates."""
296
- agents = []
297
- if self.system_templates_dir.exists():
298
- for template_file in sorted(self.system_templates_dir.glob("*.json")):
299
- if "backup" not in template_file.name.lower():
300
- agent = self._load_agent_from_template(template_file, "system")
301
- if agent:
302
- agents.append(agent)
303
- return agents
304
-
305
- def _discover_project_agents(self) -> List[AgentInfo]:
306
- """Discover project-specific agents."""
307
- agents = []
308
- if self.project_agents_dir.exists():
309
- for template_file in sorted(self.project_agents_dir.glob("*.json")):
310
- agent = self._load_agent_from_template(template_file, "project")
311
- if agent:
312
- agents.append(agent)
313
- return agents
314
-
315
- def _discover_user_agents(self) -> List[AgentInfo]:
316
- """Discover user-level agents."""
317
- agents = []
318
- if self.user_agents_dir.exists():
319
- for template_file in sorted(self.user_agents_dir.glob("*.json")):
320
- agent = self._load_agent_from_template(template_file, "user")
321
- if agent:
322
- agents.append(agent)
323
- return agents
324
-
325
- def _load_agent_from_template(
326
- self, template_file: Path, category: str
327
- ) -> Optional[AgentInfo]:
328
- """Load agent information from a template file."""
329
- try:
330
- with open(template_file) as f:
331
- data = json.load(f)
332
-
333
- agent_id = data.get("agent_id", template_file.stem)
334
- metadata = data.get("metadata", {})
335
- capabilities = data.get("capabilities", {})
336
-
337
- agent = AgentInfo(
338
- name=agent_id.replace("-agent", "").replace("_", "-"),
339
- category=category,
340
- template_path=template_file,
341
- description=metadata.get("description", "No description"),
342
- version=data.get("agent_version", metadata.get("version", "1.0.0")),
343
- tools=capabilities.get("tools", []),
344
- model=metadata.get("model"),
345
- )
346
-
347
- agent.metadata = metadata
348
-
349
- # Get file modification time
350
- stat = template_file.stat()
351
- agent.last_modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
352
-
353
- return agent
354
-
355
- except Exception:
356
- return None
357
-
358
-
359
- class ViewAgentPropertiesDialog(ModalScreen):
360
- """Modal dialog for viewing agent properties."""
361
-
362
- def __init__(self, agent: AgentInfo):
363
- super().__init__()
364
- self.agent = agent
365
-
366
- def compose(self) -> ComposeResult:
367
- with Container(id="view-properties-dialog"):
368
- yield Label(f"Agent Properties: {self.agent.name}", id="properties-title")
369
-
370
- # Load and display the JSON template
371
- try:
372
- with open(self.agent.template_path) as f:
373
- template_data = json.load(f)
374
- json_text = json.dumps(template_data, indent=2)
375
- except Exception as e:
376
- json_text = f"Error loading template: {e}"
377
-
378
- yield TextArea(json_text, read_only=True, id="properties-viewer")
379
-
380
- with Horizontal(id="properties-buttons"):
381
- yield Button("Close", variant="primary", id="close-properties")
382
-
383
- @on(Button.Pressed, "#close-properties")
384
- def on_close(self):
385
- self.dismiss()
386
-
387
-
388
- class AgentManagementScreen(Container):
389
- """Comprehensive 3-pane agent management screen."""
390
-
391
- def __init__(
392
- self, agent_manager: SimpleAgentManager, id: str = "agent-management-screen"
393
- ):
394
- super().__init__(id=id)
395
- self.agent_manager = agent_manager
396
- self.project_dir = Path.cwd()
397
- self.discovery = AgentDiscovery(self.project_dir)
398
- self.all_agents = {}
399
- self.current_category = "system"
400
- self.selected_agent = None
401
- self.current_pane_focus = 0 # 0=categories, 1=list, 2=details
402
-
403
- def compose(self) -> ComposeResult:
404
- # Simple vertical layout
405
- yield Label("Agent Management", id="screen-title")
406
-
407
- # Category tabs
408
- with TabbedContent(id="agent-category-tabs"):
409
- with TabPane("System", id="tab-system"):
410
- yield Label("System agents")
411
- with TabPane("Project", id="tab-project"):
412
- yield Label("Project agents")
413
- with TabPane("User", id="tab-user"):
414
- yield Label("User agents")
415
-
416
- # Search box
417
- yield Input(placeholder="Search agents...", id="agent-search")
418
-
419
- # Agent table with proper initialization
420
- table = DataTable(id="agent-list-table", cursor_type="row")
421
- table.add_columns("Name", "Status", "Version", "Path")
422
- yield table
423
-
424
- # Simple details area
425
- yield Static("Select an agent to view details", id="agent-details")
426
-
427
- # Action buttons in a horizontal container
428
- with Horizontal(id="agent-action-buttons"):
429
- yield Button("Deploy/Undeploy", id="toggle-deploy", variant="primary")
430
- yield Button("View Properties", id="view-properties", variant="default")
431
- yield Button("Edit", id="edit-agent", variant="warning")
432
- yield Button("Delete", id="delete-agent", variant="error")
433
-
434
- def on_mount(self):
435
- """Initialize when screen is mounted."""
436
- # Load agents immediately since we've already set up columns in compose()
437
- self.load_agents()
438
- self.update_action_buttons()
439
-
440
- def scroll_to_pane(self, pane_id: str):
441
- """Simplified scroll method - no longer needed with vertical layout."""
442
-
443
- def focus_next_pane(self):
444
- """Simplified focus navigation - just focus table."""
445
- try:
446
- table = self.query_one("#agent-list-table", DataTable)
447
- table.focus()
448
- except Exception:
449
- pass
450
-
451
- def focus_previous_pane(self):
452
- """Simplified focus navigation - just focus table."""
453
- try:
454
- table = self.query_one("#agent-list-table", DataTable)
455
- table.focus()
456
- except Exception:
457
- pass
458
-
459
- def load_agents(self):
460
- """Load all agents from all sources."""
461
- self.all_agents = self.discovery.discover_all_agents()
462
-
463
- # Debug: log what we found
464
- for category, agents in self.all_agents.items():
465
- self.log(f"Found {len(agents)} {category} agents")
466
-
467
- # Load the current category into the table
468
- self.log(f"Loading initial category: {self.current_category}")
469
- self.load_category_agents(self.current_category)
470
-
471
- def load_category_agents(self, category: str):
472
- """Load agents from a specific category into the table."""
473
- self.current_category = category
474
- table = self.query_one("#agent-list-table", DataTable)
475
-
476
- # Clear only the rows, not the columns
477
- table.clear()
478
-
479
- agents = self.all_agents.get(category, [])
480
- self.log(f"Loading {len(agents)} agents in category '{category}'")
481
-
482
- for agent in agents:
483
- status = "✓ Deployed" if agent.is_deployed else "✗ Not Deployed"
484
- # Show relative path for readability
485
- try:
486
- rel_path = agent.template_path.relative_to(Path.home())
487
- path_str = f"~/{rel_path}"
488
- except Exception:
489
- try:
490
- rel_path = agent.template_path.relative_to(self.project_dir)
491
- path_str = f"./{rel_path}"
492
- except Exception:
493
- path_str = str(agent.template_path)
494
-
495
- self.log(f"Adding row: {agent.name}, {status}, {agent.version}, {path_str}")
496
- table.add_row(agent.name, status, agent.version, path_str, key=agent.name)
497
-
498
- # Force a refresh of the table and ensure visibility
499
- table.refresh()
500
- table.visible = True
501
-
502
- # Debug: Log the table state
503
- self.log(f"Table now has {table.row_count} rows, visible={table.visible}")
504
- if table.row_count == 0:
505
- self.log("WARNING: No rows were added to the table!")
506
-
507
- @on(TabbedContent.TabActivated)
508
- def on_tab_changed(self, event: TabbedContent.TabActivated):
509
- """Handle category tab changes."""
510
- tab_id = event.pane.id
511
- if tab_id:
512
- category = tab_id.replace("tab-", "")
513
- self.load_category_agents(category)
514
- self.selected_agent = None
515
- self.update_agent_details()
516
- self.update_action_buttons()
517
-
518
- @on(DataTable.RowSelected)
519
- def on_table_row_selected(self, event: DataTable.RowSelected):
520
- """Handle agent selection from table."""
521
- if event.row_key:
522
- agent_name = str(event.row_key.value)
523
- # Find the agent in current category
524
- agents = self.all_agents.get(self.current_category, [])
525
- agent = next((a for a in agents if a.name == agent_name), None)
526
- if agent:
527
- self.selected_agent = agent
528
- self.update_agent_details()
529
- self.update_action_buttons()
530
-
531
- def update_agent_details(self):
532
- """Update the agent details pane."""
533
- details = self.query_one("#agent-details", Static)
534
-
535
- if not self.selected_agent:
536
- details.update("Select an agent to view details")
537
- return
538
-
539
- agent = self.selected_agent
540
-
541
- # Build detailed information
542
- details_text = f"""[bold]{agent.name}[/bold]
543
-
544
- [bold]Category:[/bold] {agent.category.title()}
545
- [bold]Version:[/bold] {agent.version}
546
- [bold]Deployment:[/bold] {'✓ Deployed' if agent.is_deployed else '✗ Not Deployed'}
547
-
548
- [bold]Description:[/bold]
549
- {agent.description}
550
-
551
- [bold]Template Path:[/bold]
552
- {agent.template_path}
553
- """
554
-
555
- if agent.is_deployed and agent.deployment_path:
556
- details_text += f"\n[bold]Deployed To:[/bold]\n{agent.deployment_path}\n"
557
-
558
- if agent.model:
559
- details_text += f"\n[bold]Model:[/bold] {agent.model}\n"
560
-
561
- if agent.tools:
562
- details_text += "\n[bold]Tools:[/bold]\n"
563
- for tool in agent.tools[:10]: # Show first 10 tools
564
- details_text += f" • {tool}\n"
565
- if len(agent.tools) > 10:
566
- details_text += f" ... and {len(agent.tools) - 10} more\n"
567
-
568
- if agent.last_modified:
569
- details_text += f"\n[bold]Last Modified:[/bold] {agent.last_modified.strftime('%Y-%m-%d %H:%M')}\n"
570
-
571
- details.update(details_text)
572
-
573
- def update_action_buttons(self):
574
- """Update the state of action buttons based on selected agent."""
575
- toggle_btn = self.query_one("#toggle-deploy", Button)
576
- view_btn = self.query_one("#view-properties", Button)
577
- edit_btn = self.query_one("#edit-agent", Button)
578
- delete_btn = self.query_one("#delete-agent", Button)
579
-
580
- if not self.selected_agent:
581
- # Disable all buttons
582
- toggle_btn.disabled = True
583
- view_btn.disabled = True
584
- edit_btn.disabled = True
585
- delete_btn.disabled = True
586
- toggle_btn.label = "Deploy/Undeploy"
587
- else:
588
- agent = self.selected_agent
589
-
590
- # Toggle deploy button
591
- toggle_btn.disabled = False
592
- toggle_btn.label = "Undeploy" if agent.is_deployed else "Deploy"
593
-
594
- # View properties always enabled
595
- view_btn.disabled = False
596
-
597
- # Edit button - only for project/user agents
598
- edit_btn.disabled = agent.category == "system"
599
-
600
- # Delete button - only for project/user agents
601
- delete_btn.disabled = agent.category == "system"
602
-
603
- @work
604
- @on(Button.Pressed, "#toggle-deploy")
605
- async def on_toggle_deploy(self):
606
- """Deploy or undeploy the selected agent."""
607
- if not self.selected_agent:
608
- return
609
-
610
- agent = self.selected_agent
611
-
612
- if agent.is_deployed:
613
- # Undeploy
614
- result = await self.app.push_screen_wait(
615
- ConfirmDialog(
616
- f"Undeploy agent '{agent.name}' from .claude/agents/?",
617
- "Confirm Undeploy",
618
- )
619
- )
620
- if result:
621
- if agent.deployment_path and agent.deployment_path.exists():
622
- try:
623
- agent.deployment_path.unlink()
624
- self.notify(f"Agent '{agent.name}' undeployed")
625
- agent.is_deployed = False
626
- agent.deployment_path = None
627
- self.load_agents()
628
- self.update_agent_details()
629
- self.update_action_buttons()
630
- except Exception as e:
631
- self.notify(f"Failed to undeploy: {e}", severity="error")
632
- else:
633
- # Deploy
634
- self.deploy_agent(agent)
635
-
636
- def deploy_agent(self, agent: AgentInfo):
637
- """Deploy an agent to .claude/agents/."""
638
- try:
639
- # Create .claude/agents directory
640
- claude_agents_dir = self.project_dir / ".claude" / "agents"
641
- claude_agents_dir.mkdir(parents=True, exist_ok=True)
642
-
643
- # Load template
644
- with open(agent.template_path) as f:
645
- template_data = json.load(f)
646
-
647
- # Convert to YAML format (simplified for this example)
648
- # In production, use the actual AgentFormatConverter
649
- agent_content = self._build_agent_markdown(template_data)
650
-
651
- # Write to .claude/agents/
652
- target_file = claude_agents_dir / f"{agent.name}.md"
653
- with open(target_file, "w") as f:
654
- f.write(agent_content)
655
-
656
- self.notify(f"Agent '{agent.name}' deployed to .claude/agents/")
657
- agent.is_deployed = True
658
- agent.deployment_path = target_file
659
- self.load_agents()
660
- self.update_agent_details()
661
- self.update_action_buttons()
662
-
663
- except Exception as e:
664
- self.notify(f"Failed to deploy: {e}", severity="error")
665
-
666
- def _build_agent_markdown(self, template_data: Dict[str, Any]) -> str:
667
- """Build a simple markdown representation of the agent."""
668
- metadata = template_data.get("metadata", {})
669
- capabilities = template_data.get("capabilities", {})
670
- instructions = template_data.get("instructions", [])
671
-
672
- content = f"""---
673
- agent_id: {template_data.get('agent_id', 'unknown')}
674
- name: {metadata.get('name', 'Unknown')}
675
- version: {metadata.get('version', '1.0.0')}
676
- model: {metadata.get('model', 'claude-3-5-sonnet-20241022')}
677
- ---
678
-
679
- # {metadata.get('name', 'Agent')}
680
-
681
- {metadata.get('description', 'No description')}
682
-
683
- ## Instructions
684
-
685
- """
686
-
687
- for instruction in instructions:
688
- if isinstance(instruction, dict):
689
- content += f"- {instruction.get('content', '')}\n"
690
- else:
691
- content += f"- {instruction}\n"
692
-
693
- if capabilities.get("tools"):
694
- content += "\n## Tools\n\n"
695
- for tool in capabilities["tools"]:
696
- content += f"- {tool}\n"
697
-
698
- return content
699
-
700
- @work
701
- @on(Button.Pressed, "#view-properties")
702
- async def on_view_properties(self):
703
- """View the selected agent's properties."""
704
- if self.selected_agent:
705
- await self.app.push_screen_wait(
706
- ViewAgentPropertiesDialog(self.selected_agent)
707
- )
708
-
709
- @work
710
- @on(Button.Pressed, "#edit-agent")
711
- async def on_edit_agent(self):
712
- """Edit the selected agent (project/user only)."""
713
- if not self.selected_agent or self.selected_agent.category == "system":
714
- return
715
-
716
- try:
717
- with open(self.selected_agent.template_path) as f:
718
- template_data = json.load(f)
719
-
720
- result = await self.app.push_screen_wait(
721
- EditTemplateDialog(self.selected_agent.name, template_data)
722
- )
723
-
724
- if result:
725
- # Save the edited template
726
- with open(self.selected_agent.template_path, "w") as f:
727
- json.dump(result, f, indent=2)
728
-
729
- self.notify(f"Agent '{self.selected_agent.name}' updated")
730
- self.load_agents()
731
- self.update_agent_details()
732
-
733
- except Exception as e:
734
- self.notify(f"Failed to edit agent: {e}", severity="error")
735
-
736
- @work
737
- @on(Button.Pressed, "#delete-agent")
738
- async def on_delete_agent(self):
739
- """Delete the selected agent (project/user only)."""
740
- if not self.selected_agent or self.selected_agent.category == "system":
741
- return
742
-
743
- result = await self.app.push_screen_wait(
744
- ConfirmDialog(
745
- f"Delete agent '{self.selected_agent.name}'? This cannot be undone.",
746
- "Confirm Delete",
747
- )
748
- )
749
-
750
- if result:
751
- try:
752
- # If deployed, undeploy first
753
- if (
754
- self.selected_agent.is_deployed
755
- and self.selected_agent.deployment_path
756
- ):
757
- self.selected_agent.deployment_path.unlink(missing_ok=True)
758
-
759
- # Delete the template file
760
- self.selected_agent.template_path.unlink()
761
-
762
- self.notify(f"Agent '{self.selected_agent.name}' deleted")
763
- self.selected_agent = None
764
- self.load_agents()
765
- self.update_agent_details()
766
- self.update_action_buttons()
767
-
768
- except Exception as e:
769
- self.notify(f"Failed to delete agent: {e}", severity="error")
770
-
771
- @on(Input.Changed, "#agent-search")
772
- def on_search_changed(self, event: Input.Changed):
773
- """Filter agents based on search input."""
774
- search_term = event.value.lower()
775
- table = self.query_one("#agent-list-table", DataTable)
776
- table.clear()
777
-
778
- agents = self.all_agents.get(self.current_category, [])
779
-
780
- if search_term:
781
- # Filter agents
782
- filtered = [
783
- agent
784
- for agent in agents
785
- if search_term in agent.name.lower()
786
- or search_term in agent.description.lower()
787
- ]
788
- else:
789
- filtered = agents
790
-
791
- # Repopulate table with filtered agents
792
- for agent in filtered:
793
- status = "✓ Deployed" if agent.is_deployed else "✗ Not Deployed"
794
- try:
795
- rel_path = agent.template_path.relative_to(Path.home())
796
- path_str = f"~/{rel_path}"
797
- except Exception:
798
- try:
799
- rel_path = agent.template_path.relative_to(self.project_dir)
800
- path_str = f"./{rel_path}"
801
- except Exception:
802
- path_str = str(agent.template_path)
803
-
804
- table.add_row(agent.name, status, agent.version, path_str, key=agent.name)
805
-
806
-
807
- class TemplateEditingScreen(Container):
808
- """Screen for template editing."""
809
-
810
- def __init__(
811
- self,
812
- agent_manager: SimpleAgentManager,
813
- current_scope: str,
814
- project_dir: Path,
815
- id: str = "template-screen",
816
- ):
817
- super().__init__(id=id)
818
- self.agent_manager = agent_manager
819
- self.current_scope = current_scope
820
- self.project_dir = project_dir
821
- self.templates = []
822
-
823
- def compose(self) -> ComposeResult:
824
- yield Label("Template Editing", id="screen-title")
825
-
826
- with Horizontal(id="template-layout"):
827
- # Template list
828
- with Vertical(id="template-list-container"):
829
- yield Label("Templates", classes="pane-title")
830
- yield ListView(id="template-list")
831
-
832
- # Template viewer
833
- with Vertical(id="template-viewer-container"):
834
- yield Label("Content", classes="pane-title")
835
- yield TextArea("", read_only=True, id="template-viewer")
836
- with Horizontal(id="template-actions"):
837
- yield Button("Edit", id="edit-template", variant="primary")
838
- yield Button("Create Copy", id="copy-template", variant="default")
839
- yield Button("Reset", id="reset-template", variant="warning")
840
-
841
- def on_mount(self):
842
- """Load templates when screen is mounted."""
843
- # Use after_refresh to ensure UI is ready
844
- self.call_after_refresh(self.load_templates)
845
-
846
- def load_templates(self):
847
- """Load available templates."""
848
- self.templates = []
849
- agents = self.agent_manager.discover_agents()
850
-
851
- list_view = self.query_one("#template-list", ListView)
852
- # Clear existing items
853
- list_view.clear()
854
-
855
- # Create list items and append them
856
- items_to_add = []
857
- for agent in agents:
858
- template_path = self._get_agent_template_path(agent.name)
859
- is_custom = not str(template_path).startswith(
860
- str(self.agent_manager.templates_dir)
861
- )
862
-
863
- label = f"{agent.name} {'(custom)' if is_custom else '(system)'}"
864
- list_item = ListItem(Label(label))
865
- list_item.data = {
866
- "name": agent.name,
867
- "path": template_path,
868
- "is_custom": is_custom,
869
- }
870
- items_to_add.append(list_item)
871
- self.templates.append((agent.name, template_path, is_custom))
872
-
873
- # Batch append all items at once
874
- for item in items_to_add:
875
- list_view.append(item)
876
-
877
- # Log what we loaded
878
- self.log(f"Loaded {len(items_to_add)} templates")
879
-
880
- def _get_agent_template_path(self, agent_name: str) -> Path:
881
- """Get the path to an agent's template file."""
882
- if self.current_scope == "project":
883
- config_dir = self.project_dir / ".claude-mpm" / "agents"
884
- else:
885
- config_dir = Path.home() / ".claude-mpm" / "agents"
886
-
887
- config_dir.mkdir(parents=True, exist_ok=True)
888
- custom_template = config_dir / f"{agent_name}.json"
889
-
890
- if custom_template.exists():
891
- return custom_template
892
-
893
- possible_names = [
894
- f"{agent_name}.json",
895
- f"{agent_name.replace('-', '_')}.json",
896
- f"{agent_name}-agent.json",
897
- f"{agent_name.replace('-', '_')}_agent.json",
898
- ]
899
-
900
- for name in possible_names:
901
- system_template = self.agent_manager.templates_dir / name
902
- if system_template.exists():
903
- return system_template
904
-
905
- return custom_template
906
-
907
- @on(ListView.Selected)
908
- def on_template_selected(self, event: ListView.Selected):
909
- """Display selected template."""
910
- if event.item and hasattr(event.item, "data"):
911
- data = event.item.data
912
- template_path = data["path"]
913
-
914
- if template_path.exists():
915
- with open(template_path) as f:
916
- template = json.load(f)
917
-
918
- viewer = self.query_one("#template-viewer", TextArea)
919
- viewer.text = json.dumps(template, indent=2)
920
-
921
- # Update button states
922
- edit_btn = self.query_one("#edit-template", Button)
923
- copy_btn = self.query_one("#copy-template", Button)
924
- reset_btn = self.query_one("#reset-template", Button)
925
-
926
- if data["is_custom"]:
927
- edit_btn.disabled = False
928
- copy_btn.disabled = True
929
- reset_btn.disabled = False
930
- else:
931
- edit_btn.disabled = True
932
- copy_btn.disabled = False
933
- reset_btn.disabled = True
934
-
935
- @work
936
- @on(Button.Pressed, "#edit-template")
937
- async def on_edit_template(self):
938
- """Edit the selected template."""
939
- list_view = self.query_one("#template-list", ListView)
940
- if list_view.highlighted and hasattr(list_view.highlighted, "data"):
941
- data = list_view.highlighted.data
942
- viewer = self.query_one("#template-viewer", TextArea)
943
-
944
- try:
945
- template = json.loads(viewer.text)
946
- result = await self.app.push_screen_wait(
947
- EditTemplateDialog(data["name"], template)
948
- )
949
-
950
- if result:
951
- # Save the edited template
952
- with open(data["path"], "w") as f:
953
- json.dump(result, f, indent=2)
954
-
955
- viewer.text = json.dumps(result, indent=2)
956
- self.notify(f"Template '{data['name']}' saved")
957
- except json.JSONDecodeError:
958
- self.notify("Invalid JSON in viewer", severity="error")
959
-
960
- @work
961
- @on(Button.Pressed, "#copy-template")
962
- async def on_copy_template(self):
963
- """Create a custom copy of a system template."""
964
- list_view = self.query_one("#template-list", ListView)
965
- if list_view.highlighted and hasattr(list_view.highlighted, "data"):
966
- data = list_view.highlighted.data
967
-
968
- if not data["is_custom"]:
969
- viewer = self.query_one("#template-viewer", TextArea)
970
- try:
971
- template = json.loads(viewer.text)
972
-
973
- if self.current_scope == "project":
974
- config_dir = self.project_dir / ".claude-mpm" / "agents"
975
- else:
976
- config_dir = Path.home() / ".claude-mpm" / "agents"
977
-
978
- config_dir.mkdir(parents=True, exist_ok=True)
979
- custom_path = config_dir / f"{data['name']}.json"
980
-
981
- proceed = True
982
- if custom_path.exists():
983
- proceed = await self.app.push_screen_wait(
984
- ConfirmDialog(
985
- "Custom template already exists. Overwrite?",
986
- "Confirm Overwrite",
987
- )
988
- )
989
-
990
- if proceed:
991
- with open(custom_path, "w") as f:
992
- json.dump(template, f, indent=2)
993
-
994
- self.load_templates()
995
- self.notify(f"Created custom template for '{data['name']}'")
996
-
997
- except json.JSONDecodeError:
998
- self.notify("Invalid JSON in viewer", severity="error")
999
-
1000
- @work
1001
- @on(Button.Pressed, "#reset-template")
1002
- async def on_reset_template(self):
1003
- """Reset a custom template to system defaults."""
1004
- list_view = self.query_one("#template-list", ListView)
1005
- if list_view.highlighted and hasattr(list_view.highlighted, "data"):
1006
- data = list_view.highlighted.data
1007
-
1008
- if data["is_custom"]:
1009
- result = await self.app.push_screen_wait(
1010
- ConfirmDialog(
1011
- f"Reset '{data['name']}' to system defaults?", "Confirm Reset"
1012
- )
1013
- )
1014
-
1015
- if result:
1016
- data["path"].unlink(missing_ok=True)
1017
- self.load_templates()
1018
- self.notify(f"Template '{data['name']}' reset to defaults")
1019
-
1020
-
1021
- class BehaviorFilesScreen(Container):
1022
- """Screen for behavior file management."""
1023
-
1024
- def __init__(
1025
- self, current_scope: str, project_dir: Path, id: str = "behavior-screen"
1026
- ):
1027
- super().__init__(id=id)
1028
- self.current_scope = current_scope
1029
- self.project_dir = project_dir
1030
-
1031
- def compose(self) -> ComposeResult:
1032
- yield Label("Behavior Files", id="screen-title")
1033
-
1034
- with Horizontal(id="behavior-layout"):
1035
- # File tree
1036
- with Vertical(id="file-tree-container"):
1037
- yield Label("Files", classes="pane-title")
1038
- tree = Tree("Behavior Files", id="behavior-tree")
1039
- tree.root.expand()
1040
- yield tree
1041
-
1042
- # File editor
1043
- with Vertical(id="file-editor-container"):
1044
- yield Label("Content", classes="pane-title", id="editor-title")
1045
- yield TextArea("", id="behavior-editor")
1046
- with Horizontal(id="behavior-actions"):
1047
- yield Button("Save", id="save-behavior", variant="primary")
1048
- yield Button("Import", id="import-behavior", variant="default")
1049
- yield Button("Export", id="export-behavior", variant="default")
1050
-
1051
- def on_mount(self):
1052
- """Load behavior files when screen is mounted."""
1053
- # Use after_refresh to ensure UI is ready
1054
- self.call_after_refresh(self.load_behavior_files)
1055
-
1056
- def load_behavior_files(self):
1057
- """Load and display behavior files."""
1058
- if self.current_scope == "project":
1059
- config_dir = self.project_dir / ".claude-mpm" / "behaviors"
1060
- else:
1061
- config_dir = Path.home() / ".claude-mpm" / "behaviors"
1062
-
1063
- config_dir.mkdir(parents=True, exist_ok=True)
1064
-
1065
- tree = self.query_one("#behavior-tree", Tree)
1066
- tree.clear()
1067
-
1068
- # Add identity and workflow files
1069
- for filename in ["identity.yaml", "workflow.yaml"]:
1070
- file_path = config_dir / filename
1071
- node = tree.root.add(filename)
1072
- node.data = file_path
1073
-
1074
- if file_path.exists():
1075
- node.set_label(f"{filename} ✓")
1076
- else:
1077
- node.set_label(f"{filename} ✗")
1078
-
1079
- @on(Tree.NodeSelected)
1080
- def on_node_selected(self, event: Tree.NodeSelected):
1081
- """Load file content when node is selected."""
1082
- if event.node.data:
1083
- file_path = event.node.data
1084
- editor = self.query_one("#behavior-editor", TextArea)
1085
-
1086
- if file_path.exists():
1087
- with open(file_path) as f:
1088
- editor.text = f.read()
1089
- editor.read_only = False
1090
- else:
1091
- editor.text = f"# {file_path.name}\n# File does not exist yet\n"
1092
- editor.read_only = False
1093
-
1094
- # Update editor title
1095
- title = self.query_one("#editor-title", Label)
1096
- title.update(f"{file_path.name} ──────")
1097
-
1098
- @on(Button.Pressed, "#save-behavior")
1099
- def on_save_behavior(self):
1100
- """Save the current behavior file."""
1101
- tree = self.query_one("#behavior-tree", Tree)
1102
- if tree.cursor_node and tree.cursor_node.data:
1103
- file_path = tree.cursor_node.data
1104
- editor = self.query_one("#behavior-editor", TextArea)
1105
-
1106
- file_path.parent.mkdir(parents=True, exist_ok=True)
1107
- with open(file_path, "w") as f:
1108
- f.write(editor.text)
1109
-
1110
- # Update tree node
1111
- tree.cursor_node.set_label(f"{file_path.name} ✓")
1112
- self.notify(f"Saved {file_path.name}")
1113
-
1114
- @on(Button.Pressed, "#import-behavior")
1115
- async def on_import_behavior(self):
1116
- """Import a behavior file."""
1117
- # In a real implementation, this would open a file dialog
1118
- self.notify(
1119
- "Import functionality would open a file dialog", severity="information"
1120
- )
1121
-
1122
- @on(Button.Pressed, "#export-behavior")
1123
- async def on_export_behavior(self):
1124
- """Export a behavior file."""
1125
- tree = self.query_one("#behavior-tree", Tree)
1126
- if tree.cursor_node and tree.cursor_node.data:
1127
- file_path = tree.cursor_node.data
1128
- if file_path.exists():
1129
- # In a real implementation, this would open a save dialog
1130
- self.notify(
1131
- f"Would export {file_path.name} to chosen location",
1132
- severity="information",
1133
- )
1134
- else:
1135
- self.notify("File does not exist", severity="error")
1136
-
1137
-
1138
- class SettingsScreen(Container):
1139
- """Screen for settings and version information."""
1140
-
1141
- def __init__(
1142
- self, current_scope: str, project_dir: Path, id: str = "settings-screen"
1143
- ):
1144
- super().__init__(id=id)
1145
- self.current_scope = current_scope
1146
- self.project_dir = project_dir
1147
- self.version_service = VersionService()
1148
-
1149
- def compose(self) -> ComposeResult:
1150
- yield Label("Settings", id="screen-title")
1151
-
1152
- with Vertical(id="settings-content"):
1153
- # Scope settings
1154
- with Container(id="scope-section", classes="settings-section"):
1155
- yield Label("Configuration Scope", classes="section-title")
1156
- with Horizontal(classes="setting-row"):
1157
- yield Label("Current Scope:", classes="setting-label")
1158
- yield Label(
1159
- self.current_scope.upper(),
1160
- id="current-scope",
1161
- classes="setting-value",
1162
- )
1163
- yield Button("Switch", id="switch-scope", variant="default")
1164
-
1165
- with Horizontal(classes="setting-row"):
1166
- yield Label("Directory:", classes="setting-label")
1167
- yield Label(
1168
- str(self.project_dir), id="current-dir", classes="setting-value"
1169
- )
1170
-
1171
- # Version information
1172
- with Container(id="version-section", classes="settings-section"):
1173
- yield Label("Version Information", classes="section-title")
1174
- yield Container(id="version-info")
1175
-
1176
- # Export/Import
1177
- with Container(id="export-section", classes="settings-section"):
1178
- yield Label("Configuration Management", classes="section-title")
1179
- with Horizontal(classes="setting-row"):
1180
- yield Button(
1181
- "Export Configuration", id="export-config", variant="primary"
1182
- )
1183
- yield Button(
1184
- "Import Configuration", id="import-config", variant="default"
1185
- )
1186
-
1187
- def on_mount(self):
1188
- """Load version information when screen is mounted."""
1189
- # Use after_refresh to ensure UI is ready
1190
- self.call_after_refresh(self.load_version_info)
1191
-
1192
- def load_version_info(self):
1193
- """Load and display version information."""
1194
- mpm_version = self.version_service.get_version()
1195
- build_number = self.version_service.get_build_number()
1196
-
1197
- # Try to get Claude version
1198
- claude_version = "Unknown"
1199
- try:
1200
- import subprocess
1201
-
1202
- result = subprocess.run(
1203
- ["claude", "--version"],
1204
- capture_output=True,
1205
- text=True,
1206
- timeout=5,
1207
- check=False,
1208
- )
1209
- if result.returncode == 0:
1210
- claude_version = result.stdout.strip()
1211
- except Exception:
1212
- pass
1213
-
1214
- version_container = self.query_one("#version-info", Container)
1215
- version_container.remove_children()
1216
-
1217
- version_text = f"""Claude MPM: v{mpm_version} (build {build_number})
1218
- Claude Code: {claude_version}
1219
- Python: {sys.version.split()[0]}"""
1220
-
1221
- for line in version_text.split("\n"):
1222
- version_container.mount(Label(line, classes="version-line"))
1223
-
1224
- @on(Button.Pressed, "#switch-scope")
1225
- def on_switch_scope(self):
1226
- """Switch configuration scope."""
1227
- self.current_scope = "user" if self.current_scope == "project" else "project"
1228
-
1229
- scope_label = self.query_one("#current-scope", Label)
1230
- scope_label.update(self.current_scope.upper())
1231
-
1232
- # Update agent manager in the app and all screens
1233
- if hasattr(self.app, "agent_manager"):
1234
- if self.current_scope == "project":
1235
- config_dir = self.project_dir / ".claude-mpm"
1236
- else:
1237
- config_dir = Path.home() / ".claude-mpm"
1238
- self.app.agent_manager = SimpleAgentManager(config_dir)
1239
-
1240
- # Update all screens with new scope
1241
- try:
1242
- switcher = self.app.query_one("#content-switcher", ContentSwitcher)
1243
-
1244
- # Update each screen's scope
1245
- for screen_id in ["agents", "templates", "behaviors", "settings"]:
1246
- screen = switcher.get_child_by_id(screen_id)
1247
- if screen and hasattr(screen, "current_scope"):
1248
- screen.current_scope = self.current_scope
1249
- if screen and hasattr(screen, "agent_manager"):
1250
- screen.agent_manager = self.app.agent_manager
1251
-
1252
- # Reload data in the current screen if it has a load method
1253
- current_screen = switcher.get_child_by_id(self.app.current_screen_name)
1254
- if current_screen:
1255
- if hasattr(current_screen, "load_agents"):
1256
- current_screen.load_agents()
1257
- elif hasattr(current_screen, "load_templates"):
1258
- current_screen.load_templates()
1259
- elif hasattr(current_screen, "load_behavior_files"):
1260
- current_screen.load_behavior_files()
1261
- except Exception:
1262
- pass
1263
-
1264
- self.notify(f"Switched to {self.current_scope} scope")
1265
-
1266
- @on(Button.Pressed, "#export-config")
1267
- async def on_export_config(self):
1268
- """Export configuration."""
1269
- # In a real implementation, this would open a save dialog
1270
- self.notify(
1271
- "Export functionality would save configuration to chosen file",
1272
- severity="information",
1273
- )
1274
-
1275
- @on(Button.Pressed, "#import-config")
1276
- async def on_import_config(self):
1277
- """Import configuration."""
1278
- # In a real implementation, this would open a file dialog
1279
- self.notify(
1280
- "Import functionality would load configuration from chosen file",
1281
- severity="information",
1282
- )
1283
-
1284
-
1285
- class ConfigureTUI(App):
1286
- """Main Textual application for configuration management."""
1287
-
1288
- CSS = """
1289
- /* Global styles */
1290
- Container {
1291
- background: $surface;
1292
- }
1293
-
1294
- #screen-title {
1295
- text-style: bold;
1296
- text-align: left;
1297
- padding: 0 1;
1298
- height: 1;
1299
- background: $primary 30%;
1300
- color: $text;
1301
- margin-bottom: 1;
1302
- border-bottom: solid $primary;
1303
- }
1304
-
1305
- /* Header styles */
1306
- Header {
1307
- background: $primary;
1308
- border-bottom: solid $accent;
1309
- }
1310
-
1311
- /* Main layout */
1312
- #main-layout {
1313
- height: 100%;
1314
- }
1315
-
1316
- /* Sidebar navigation - Clean minimal style */
1317
- #sidebar {
1318
- width: 25;
1319
- background: $panel;
1320
- border-right: solid $primary;
1321
- padding: 0;
1322
- }
1323
-
1324
- .sidebar-title {
1325
- text-style: bold;
1326
- padding: 0 1;
1327
- height: 1;
1328
- background: $primary 20%;
1329
- text-align: left;
1330
- margin-bottom: 0;
1331
- border-bottom: solid $primary;
1332
- }
1333
-
1334
- #nav-list {
1335
- height: 100%;
1336
- padding: 0;
1337
- margin: 0;
1338
- }
1339
-
1340
- /* Single-line list items with minimal styling */
1341
- #nav-list > ListItem {
1342
- padding: 0 2;
1343
- margin: 0;
1344
- height: 1; /* Single line height */
1345
- background: transparent;
1346
- }
1347
-
1348
- #nav-list > ListItem Label {
1349
- padding: 0;
1350
- margin: 0;
1351
- width: 100%;
1352
- }
1353
-
1354
- /* Hover state - light background */
1355
- #nav-list > ListItem:hover {
1356
- background: $boost;
1357
- }
1358
-
1359
- /* Highlighted/Selected state - accent background */
1360
- #nav-list > ListItem.--highlight {
1361
- background: $accent 30%;
1362
- text-style: bold;
1363
- }
1364
-
1365
- /* Active selected state - primary background with bold text */
1366
- #nav-list > ListItem.active {
1367
- background: $primary 50%;
1368
- text-style: bold;
1369
- }
1370
-
1371
- /* Main content area */
1372
- #content-switcher {
1373
- padding: 1;
1374
- height: 100%;
1375
- width: 100%;
1376
- }
1377
-
1378
- /* Content screens (Containers) */
1379
- #agents, #templates, #behaviors, #settings {
1380
- height: 100%;
1381
- width: 100%;
1382
- }
1383
-
1384
- /* Agent Management simplified layout styles */
1385
- #agent-management-screen {
1386
- height: 100%;
1387
- padding: 1;
1388
- }
1389
-
1390
- #screen-title {
1391
- text-style: bold;
1392
- padding: 0 1;
1393
- height: 1;
1394
- background: $primary 20%;
1395
- text-align: left;
1396
- margin-bottom: 1;
1397
- border-bottom: solid $primary;
1398
- }
1399
-
1400
- /* Compact headers for all screens */
1401
- #list-title, #viewer-title, #tree-title, #editor-title {
1402
- text-style: bold;
1403
- padding: 0 1;
1404
- height: 1;
1405
- background: $primary 20%;
1406
- text-align: left;
1407
- margin-bottom: 1;
1408
- border-bottom: solid $primary;
1409
- }
1410
-
1411
- #agent-search {
1412
- margin-bottom: 1;
1413
- width: 100%;
1414
- }
1415
-
1416
- #agent-list-table {
1417
- height: 20;
1418
- min-height: 15;
1419
- margin-bottom: 1;
1420
- border: solid $primary;
1421
- }
1422
-
1423
- #agent-details {
1424
- padding: 1;
1425
- height: 10;
1426
- border: solid $primary;
1427
- margin-bottom: 1;
1428
- }
1429
-
1430
- #agent-action-buttons {
1431
- height: 3;
1432
- align: center middle;
1433
- }
1434
-
1435
- #agent-action-buttons Button {
1436
- margin: 0 1;
1437
- }
1438
-
1439
- #agent-category-tabs {
1440
- height: 3;
1441
- margin-bottom: 1;
1442
- }
1443
-
1444
- #agent-category-tabs TabPane {
1445
- padding: 0;
1446
- }
1447
-
1448
- #view-properties-dialog {
1449
- align: center middle;
1450
- background: $panel;
1451
- border: thick $primary;
1452
- padding: 2;
1453
- margin: 2 4;
1454
- width: 90%;
1455
- height: 80%;
1456
- }
1457
-
1458
- #properties-title {
1459
- text-style: bold;
1460
- margin-bottom: 1;
1461
- }
1462
-
1463
- #properties-viewer {
1464
- width: 100%;
1465
- height: 100%;
1466
- margin: 1 0;
1467
- }
1468
-
1469
- #properties-buttons {
1470
- align: center middle;
1471
- height: 3;
1472
- margin-top: 1;
1473
- }
1474
-
1475
- /* Template screen styles */
1476
- #template-layout {
1477
- height: 100%;
1478
- }
1479
-
1480
- #template-list-container {
1481
- width: 40%;
1482
- border-right: solid $primary;
1483
- padding-right: 1;
1484
- }
1485
-
1486
- #template-viewer-container {
1487
- width: 60%;
1488
- padding-left: 1;
1489
- }
1490
-
1491
- #template-viewer {
1492
- height: 100%;
1493
- }
1494
-
1495
- #template-actions {
1496
- align: center middle;
1497
- height: 3;
1498
- margin-top: 1;
1499
- }
1500
-
1501
- #template-actions Button {
1502
- margin: 0 1;
1503
- }
1504
-
1505
- /* Behavior screen styles */
1506
- #behavior-layout {
1507
- height: 100%;
1508
- }
1509
-
1510
- #file-tree-container {
1511
- width: 30%;
1512
- border-right: solid $primary;
1513
- padding-right: 1;
1514
- }
1515
-
1516
- #file-editor-container {
1517
- width: 70%;
1518
- padding-left: 1;
1519
- }
1520
-
1521
- #behavior-editor {
1522
- height: 100%;
1523
- }
1524
-
1525
- #behavior-actions {
1526
- align: center middle;
1527
- height: 3;
1528
- margin-top: 1;
1529
- }
1530
-
1531
- #behavior-actions Button {
1532
- margin: 0 1;
1533
- }
1534
-
1535
- /* Settings screen styles */
1536
- #settings-content {
1537
- padding: 2;
1538
- max-width: 80;
1539
- }
1540
-
1541
- .settings-section {
1542
- margin-bottom: 2;
1543
- border: solid $primary;
1544
- padding: 1;
1545
- }
1546
-
1547
- .section-title {
1548
- text-style: bold;
1549
- padding: 0 1;
1550
- height: 1;
1551
- margin-bottom: 1;
1552
- color: $primary;
1553
- border-bottom: solid $primary;
1554
- }
1555
-
1556
- .setting-row {
1557
- align: left middle;
1558
- height: 3;
1559
- }
1560
-
1561
- .setting-label {
1562
- width: 20;
1563
- }
1564
-
1565
- .setting-value {
1566
- width: 40;
1567
- color: $text-muted;
1568
- }
1569
-
1570
- .version-line {
1571
- padding: 0 1;
1572
- margin: 0;
1573
- }
1574
-
1575
- /* Modal dialog styles */
1576
- #confirm-dialog, #edit-dialog {
1577
- align: center middle;
1578
- background: $panel;
1579
- border: thick $primary;
1580
- padding: 2;
1581
- margin: 4 8;
1582
- }
1583
-
1584
- #confirm-title, #edit-title {
1585
- text-style: bold;
1586
- margin-bottom: 1;
1587
- }
1588
-
1589
- #confirm-message {
1590
- margin-bottom: 2;
1591
- }
1592
-
1593
- #confirm-buttons, #edit-buttons {
1594
- align: center middle;
1595
- height: 3;
1596
- }
1597
-
1598
- #confirm-buttons Button, #edit-buttons Button {
1599
- margin: 0 1;
1600
- }
1601
-
1602
- #template-editor {
1603
- width: 80;
1604
- height: 30;
1605
- margin: 1 0;
1606
- }
1607
-
1608
- /* Footer styles */
1609
- Footer {
1610
- background: $panel;
1611
- }
1612
- """
1613
-
1614
- BINDINGS = [
1615
- Binding("ctrl+a", "navigate('agents')", "Agents", key_display="^A"),
1616
- Binding("ctrl+t", "navigate('templates')", "Templates", key_display="^T"),
1617
- Binding("ctrl+b", "navigate('behaviors')", "Behaviors", key_display="^B"),
1618
- Binding("ctrl+s", "navigate('settings')", "Settings", key_display="^S"),
1619
- Binding("ctrl+q", "quit", "Quit", key_display="^Q"),
1620
- Binding("f1", "help", "Help", key_display="F1"),
1621
- Binding("enter", "select_nav", "Select", show=False),
1622
- Binding("ctrl+right", "focus_next_pane", "Next Pane", show=False),
1623
- Binding("ctrl+left", "focus_prev_pane", "Prev Pane", show=False),
1624
- ]
1625
-
1626
- def __init__(
1627
- self, current_scope: str = "project", project_dir: Optional[Path] = None
1628
- ):
1629
- super().__init__()
1630
- self.current_scope = current_scope
1631
- self.project_dir = project_dir or Path.cwd()
1632
-
1633
- # Initialize agent manager
1634
- if self.current_scope == "project":
1635
- config_dir = self.project_dir / ".claude-mpm"
1636
- else:
1637
- config_dir = Path.home() / ".claude-mpm"
1638
- self.agent_manager = SimpleAgentManager(config_dir)
1639
-
1640
- # Track current screen
1641
- self.current_screen_name = "agents"
1642
-
1643
- # Version service
1644
- self.version_service = VersionService()
1645
-
1646
- def compose(self) -> ComposeResult:
1647
- """Create the main application layout."""
1648
- # Header with version info
1649
- self.version_service.get_version()
1650
- yield Header(show_clock=True)
1651
- yield Rule(line_style="heavy")
1652
-
1653
- with Horizontal(id="main-layout"):
1654
- # Sidebar navigation
1655
- with Container(id="sidebar"):
1656
- # Use Static instead of Label for the header
1657
- yield Static("MENU", classes="sidebar-title")
1658
- # Create ListView with simple text items - no emojis, clean look
1659
- yield ListView(
1660
- ListItem(Label("Agents"), id="nav-agents"),
1661
- ListItem(Label("Templates"), id="nav-templates"),
1662
- ListItem(Label("Behaviors"), id="nav-behaviors"),
1663
- ListItem(Label("Settings"), id="nav-settings"),
1664
- id="nav-list",
1665
- )
1666
-
1667
- # Main content area with ContentSwitcher
1668
- with ContentSwitcher(initial="agents", id="content-switcher"):
1669
- # Create all screens with proper IDs for ContentSwitcher
1670
- yield AgentManagementScreen(self.agent_manager, id="agents")
1671
- yield TemplateEditingScreen(
1672
- self.agent_manager,
1673
- self.current_scope,
1674
- self.project_dir,
1675
- id="templates",
1676
- )
1677
- yield BehaviorFilesScreen(
1678
- self.current_scope, self.project_dir, id="behaviors"
1679
- )
1680
- yield SettingsScreen(
1681
- self.current_scope, self.project_dir, id="settings"
1682
- )
1683
-
1684
- # Footer with shortcuts
1685
- yield Footer()
1686
-
1687
- def on_mount(self):
1688
- """Initialize the application."""
1689
- self.title = f"Claude MPM Configuration v{self.version_service.get_version()}"
1690
- self.sub_title = f"Scope: {self.current_scope.upper()} | {self.project_dir}"
1691
-
1692
- # Get the navigation list
1693
- list_view = self.query_one("#nav-list", ListView)
1694
-
1695
- # Highlight the first navigation item
1696
- if list_view.children:
1697
- first_item = list_view.children[0]
1698
- if isinstance(first_item, ListItem):
1699
- first_item.add_class("active")
1700
-
1701
- # Set focus on the navigation list to enable keyboard navigation
1702
- list_view.focus()
1703
-
1704
- # Set initial index to 0 (highlight first item)
1705
- list_view.index = 0
1706
-
1707
- # Initialize all screens that are Containers in ContentSwitcher
1708
- # since ContentSwitcher doesn't automatically call their on_mount
1709
- def initialize_screens():
1710
- try:
1711
- # Initialize agent management screen
1712
- agent_screen = self.query_one("#agents", AgentManagementScreen)
1713
- agent_screen.on_mount()
1714
- self.log("Initialized AgentManagementScreen")
1715
-
1716
- # Initialize template screen
1717
- template_screen = self.query_one("#templates", TemplateEditingScreen)
1718
- template_screen.on_mount()
1719
- self.log("Initialized TemplateEditingScreen")
1720
-
1721
- # Initialize behavior screen
1722
- behavior_screen = self.query_one("#behaviors", BehaviorFilesScreen)
1723
- behavior_screen.on_mount()
1724
- self.log("Initialized BehaviorFilesScreen")
1725
-
1726
- # Initialize settings screen
1727
- settings_screen = self.query_one("#settings", SettingsScreen)
1728
- settings_screen.on_mount()
1729
- self.log("Initialized SettingsScreen")
1730
-
1731
- except Exception as e:
1732
- self.log(f"Error initializing screens: {e}")
1733
-
1734
- # Use call_after_refresh to ensure DOM is ready
1735
- self.call_after_refresh(initialize_screens)
1736
-
1737
- def _on_nav_index_changed(self, old_index: int, new_index: int) -> None:
1738
- """Watch for navigation list index changes as a fallback."""
1739
- if new_index is not None:
1740
- screens = ["agents", "templates", "behaviors", "settings"]
1741
- if 0 <= new_index < len(screens):
1742
- screen_name = screens[new_index]
1743
- self.log(f"Index changed to {new_index}, switching to {screen_name}")
1744
- # Only switch if it's a different screen
1745
- if screen_name != self.current_screen_name:
1746
- self.switch_screen(screen_name)
1747
-
1748
- @on(ListView.Selected, "#nav-list")
1749
- def on_nav_list_selected(self, event: ListView.Selected) -> None:
1750
- """Handle navigation ListView selection - primary handler."""
1751
- self.log("Navigation ListView.Selected triggered")
1752
-
1753
- # Map item IDs to screen names
1754
- id_to_screen = {
1755
- "nav-agents": "agents",
1756
- "nav-templates": "templates",
1757
- "nav-behaviors": "behaviors",
1758
- "nav-settings": "settings",
1759
- }
1760
-
1761
- # Try to get screen name from item ID first
1762
- if event.item and hasattr(event.item, "id") and event.item.id:
1763
- screen_name = id_to_screen.get(event.item.id)
1764
- if screen_name:
1765
- self.log(f"Selected item by ID: {event.item.id} -> {screen_name}")
1766
- self.switch_screen(screen_name)
1767
- self.notify(f"Switched to {screen_name.title()}", timeout=1)
1768
- return
1769
-
1770
- # Fallback to index-based selection
1771
- if event.list_view and event.list_view.index is not None:
1772
- screens = ["agents", "templates", "behaviors", "settings"]
1773
- index = event.list_view.index
1774
- if 0 <= index < len(screens):
1775
- screen_name = screens[index]
1776
- self.log(f"Selected by index: {index} -> {screen_name}")
1777
- self.switch_screen(screen_name)
1778
- self.notify(f"Switched to {screen_name.title()}", timeout=1)
1779
-
1780
- @on(ListView.Highlighted, "#nav-list")
1781
- def on_nav_list_highlighted(self, event: ListView.Highlighted) -> None:
1782
- """Handle ListView highlight changes for mouse hover."""
1783
- # This helps with mouse interaction - when user hovers over items
1784
- if event.list_view and event.list_view.index is not None:
1785
- self.log(f"Navigation item highlighted at index: {event.list_view.index}")
1786
-
1787
- def on_list_view_selected(self, event: ListView.Selected) -> None:
1788
- """Traditional method-name based handler as ultimate fallback."""
1789
- self.log("on_list_view_selected (traditional handler) triggered")
1790
-
1791
- # Try to get the navigation list
1792
- try:
1793
- nav_list = self.query_one("#nav-list", ListView)
1794
- if nav_list and nav_list.index is not None:
1795
- screens = ["agents", "templates", "behaviors", "settings"]
1796
- if 0 <= nav_list.index < len(screens):
1797
- screen_name = screens[nav_list.index]
1798
- self.log(f"Traditional handler: switching to {screen_name}")
1799
- self.switch_screen(screen_name)
1800
- self.notify(f"Switched to {screen_name.title()}", timeout=1)
1801
- except Exception as e:
1802
- self.log(f"Error in traditional handler: {e}")
1803
-
1804
- def switch_screen(self, screen_name: str):
1805
- """Switch to a different screen."""
1806
- if screen_name == self.current_screen_name:
1807
- return
1808
-
1809
- try:
1810
- # Use ContentSwitcher to switch screens
1811
- switcher = self.query_one("#content-switcher", ContentSwitcher)
1812
- switcher.current = screen_name
1813
- self.current_screen_name = screen_name
1814
-
1815
- # Update navigation highlight
1816
- list_view = self.query_one("#nav-list", ListView)
1817
- for item in list_view.children:
1818
- if isinstance(item, ListItem):
1819
- item.remove_class("active")
1820
- if item.id == f"nav-{screen_name}":
1821
- item.add_class("active")
1822
-
1823
- except Exception as e:
1824
- self.notify(f"Error switching screen: {e}", severity="error")
1825
-
1826
- def action_select_nav(self):
1827
- """Handle Enter key on navigation list."""
1828
- self.log("action_select_nav triggered")
1829
- try:
1830
- # Check if the navigation list has focus
1831
- list_view = self.query_one("#nav-list", ListView)
1832
- if self.focused == list_view and list_view.index is not None:
1833
- screens = ["agents", "templates", "behaviors", "settings"]
1834
- if 0 <= list_view.index < len(screens):
1835
- self.log(f"Selecting screen via Enter: {screens[list_view.index]}")
1836
- self.switch_screen(screens[list_view.index])
1837
- except Exception as e:
1838
- self.log(f"Error in action_select_nav: {e}")
1839
-
1840
- def action_navigate(self, screen: str):
1841
- """Navigate to a specific screen via keyboard shortcut."""
1842
- self.switch_screen(screen)
1843
-
1844
- # Also update the ListView selection to match
1845
- list_view = self.query_one("#nav-list", ListView)
1846
- screens = ["agents", "templates", "behaviors", "settings"]
1847
- if screen in screens:
1848
- index = screens.index(screen)
1849
- list_view.index = index
1850
-
1851
- def action_help(self):
1852
- """Show help information."""
1853
- self.notify(
1854
- "Keyboard Shortcuts:\n"
1855
- "Ctrl+A: Agent Management\n"
1856
- "Ctrl+T: Template Editing\n"
1857
- "Ctrl+B: Behavior Files\n"
1858
- "Ctrl+S: Settings\n"
1859
- "Ctrl+Q: Quit\n"
1860
- "Tab: Navigate UI elements\n"
1861
- "Ctrl+→/←: Navigate panes\n"
1862
- "Enter: Select/Activate",
1863
- title="Help",
1864
- timeout=10,
1865
- )
1866
-
1867
- def action_focus_next_pane(self):
1868
- """Focus next pane in agent management screen."""
1869
- try:
1870
- current_screen = self.query_one(
1871
- "#content-switcher", ContentSwitcher
1872
- ).current
1873
- if current_screen == "agents":
1874
- agent_screen = self.query_one("#agents", AgentManagementScreen)
1875
- agent_screen.focus_next_pane()
1876
- except Exception:
1877
- pass
1878
-
1879
- def action_focus_prev_pane(self):
1880
- """Focus previous pane in agent management screen."""
1881
- try:
1882
- current_screen = self.query_one(
1883
- "#content-switcher", ContentSwitcher
1884
- ).current
1885
- if current_screen == "agents":
1886
- agent_screen = self.query_one("#agents", AgentManagementScreen)
1887
- agent_screen.focus_previous_pane()
1888
- except Exception:
1889
- pass
1890
-
1891
-
1892
- def can_use_tui() -> bool:
1893
- """Check if the terminal supports full-screen TUI mode."""
1894
- # Check if we're in an interactive terminal
1895
- if not sys.stdin.isatty() or not sys.stdout.isatty():
1896
- return False
1897
-
1898
- # Check if we're in a supported terminal
1899
- term = os.environ.get("TERM", "")
1900
- if not term or term == "dumb":
1901
- return False
1902
-
1903
- # Check terminal size
1904
- try:
1905
- import shutil
1906
-
1907
- cols, rows = shutil.get_terminal_size()
1908
- if cols < 80 or rows < 24:
1909
- return False
1910
- except Exception:
1911
- return False
1912
-
1913
- return True
1914
-
1915
-
1916
- def launch_tui(
1917
- current_scope: str = "project", project_dir: Optional[Path] = None
1918
- ) -> CommandResult:
1919
- """Launch the Textual TUI application."""
1920
- try:
1921
- app = ConfigureTUI(current_scope, project_dir)
1922
- app.run()
1923
- return CommandResult.success_result("Configuration completed")
1924
- except KeyboardInterrupt:
1925
- return CommandResult.success_result("Configuration cancelled")
1926
- except Exception as e:
1927
- return CommandResult.error_result(f"TUI error: {e}")