claude-mpm 5.4.14__py3-none-any.whl → 5.4.36__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.

Potentially problematic release.


This version of claude-mpm might be problematic. Click here for more details.

Files changed (103) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/BASE_AGENT.md +164 -0
  3. claude_mpm/agents/BASE_ENGINEER.md +658 -0
  4. claude_mpm/agents/CLAUDE_MPM_TEACHER_OUTPUT_STYLE.md +1 -1
  5. claude_mpm/agents/MEMORY.md +1 -1
  6. claude_mpm/agents/PM_INSTRUCTIONS.md +363 -817
  7. claude_mpm/agents/WORKFLOW.md +5 -254
  8. claude_mpm/agents/agent_loader.py +1 -1
  9. claude_mpm/agents/base_agent.json +31 -0
  10. claude_mpm/cli/chrome_devtools_installer.py +175 -0
  11. claude_mpm/cli/commands/agent_state_manager.py +10 -10
  12. claude_mpm/cli/commands/agents.py +9 -40
  13. claude_mpm/cli/commands/auto_configure.py +4 -4
  14. claude_mpm/cli/commands/configure.py +1 -1
  15. claude_mpm/cli/commands/postmortem.py +1 -1
  16. claude_mpm/cli/commands/skills.py +193 -187
  17. claude_mpm/cli/interactive/agent_wizard.py +2 -2
  18. claude_mpm/cli/parsers/agents_parser.py +0 -9
  19. claude_mpm/cli/parsers/auto_configure_parser.py +0 -138
  20. claude_mpm/cli/startup.py +330 -78
  21. claude_mpm/commands/mpm-config.md +1 -2
  22. claude_mpm/commands/mpm-help.md +14 -95
  23. claude_mpm/commands/mpm-organize.md +350 -153
  24. claude_mpm/core/config.py +2 -4
  25. claude_mpm/core/framework/loaders/agent_loader.py +1 -1
  26. claude_mpm/core/framework/loaders/instruction_loader.py +52 -11
  27. claude_mpm/core/unified_agent_registry.py +1 -1
  28. claude_mpm/dashboard/static/svelte-build/_app/env.js +1 -0
  29. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B_FtCwCQ.css +1 -0
  30. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.Cl_eSA4x.css +1 -0
  31. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BgChzWQ1.js +1 -0
  32. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIXEwuWe.js +1 -0
  33. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CWc5urbQ.js +1 -0
  34. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DMkZpdF2.js +2 -0
  35. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DjhvlsAc.js +1 -0
  36. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/N4qtv3Hx.js +2 -0
  37. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/uj46x2Wr.js +1 -0
  38. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.DTL5mJO-.js +2 -0
  39. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.DzuEhzqh.js +1 -0
  40. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/0.CAGBuiOw.js +1 -0
  41. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DFLC8jdE.js +1 -0
  42. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.DPvEihJJ.js +10 -0
  43. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -0
  44. claude_mpm/dashboard/static/svelte-build/favicon.svg +7 -0
  45. claude_mpm/dashboard/static/svelte-build/index.html +36 -0
  46. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  47. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  48. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  49. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  50. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  51. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  52. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  53. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  54. claude_mpm/hooks/claude_hooks/event_handlers.py +5 -0
  55. claude_mpm/hooks/claude_hooks/hook_handler.py +149 -1
  56. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  57. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  58. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  59. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  60. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  61. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  62. claude_mpm/hooks/claude_hooks/services/connection_manager.py +26 -6
  63. claude_mpm/models/git_repository.py +3 -3
  64. claude_mpm/scripts/start_activity_logging.py +0 -0
  65. claude_mpm/services/agents/cache_git_manager.py +6 -6
  66. claude_mpm/services/agents/deployment/agent_deployment.py +7 -7
  67. claude_mpm/services/agents/deployment/agent_discovery_service.py +2 -2
  68. claude_mpm/services/agents/deployment/agent_template_builder.py +2 -2
  69. claude_mpm/services/agents/deployment/agents_directory_resolver.py +2 -2
  70. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +20 -22
  71. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +55 -53
  72. claude_mpm/services/agents/git_source_manager.py +2 -2
  73. claude_mpm/services/agents/recommender.py +5 -3
  74. claude_mpm/services/agents/single_tier_deployment_service.py +2 -2
  75. claude_mpm/services/agents/sources/git_source_sync_service.py +5 -5
  76. claude_mpm/services/agents/startup_sync.py +22 -2
  77. claude_mpm/services/command_deployment_service.py +10 -0
  78. claude_mpm/services/diagnostics/checks/agent_check.py +2 -2
  79. claude_mpm/services/diagnostics/checks/agent_sources_check.py +1 -1
  80. claude_mpm/services/git/git_operations_service.py +8 -8
  81. claude_mpm/services/monitor/server.py +473 -3
  82. claude_mpm/services/skills/selective_skill_deployer.py +475 -1
  83. claude_mpm/services/skills_deployer.py +62 -6
  84. claude_mpm/services/socketio/dashboard_server.py +1 -0
  85. claude_mpm/services/socketio/event_normalizer.py +37 -6
  86. claude_mpm/services/socketio/server/core.py +262 -123
  87. claude_mpm/utils/agent_dependency_loader.py +14 -2
  88. claude_mpm/utils/agent_filters.py +1 -1
  89. claude_mpm/utils/migration.py +4 -4
  90. claude_mpm/utils/robust_installer.py +47 -3
  91. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/METADATA +5 -3
  92. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/RECORD +96 -66
  93. claude_mpm/cli/commands/agents_detect.py +0 -380
  94. claude_mpm/cli/commands/agents_recommend.py +0 -309
  95. claude_mpm/commands/mpm-agents-auto-configure.md +0 -278
  96. claude_mpm/commands/mpm-agents-detect.md +0 -177
  97. claude_mpm/commands/mpm-agents-list.md +0 -131
  98. claude_mpm/commands/mpm-agents-recommend.md +0 -223
  99. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/WHEEL +0 -0
  100. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/entry_points.txt +0 -0
  101. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/licenses/LICENSE +0 -0
  102. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  103. {claude_mpm-5.4.14.dist-info → claude_mpm-5.4.36.dist-info}/top_level.txt +0 -0
@@ -12,6 +12,8 @@ DESIGN DECISIONS:
12
12
  - Parse YAML frontmatter from agent markdown files
13
13
  - Combine explicit + inferred skills for comprehensive coverage
14
14
  - Return set of unique skill names for filtering
15
+ - Track deployed skills in .mpm-deployed-skills.json index
16
+ - Remove orphaned skills (deployed by mpm but no longer referenced)
15
17
 
16
18
  FORMATS SUPPORTED:
17
19
  1. Legacy: skills: [skill-a, skill-b, ...]
@@ -23,14 +25,21 @@ SKILL DISCOVERY FLOW:
23
25
  3. Query SkillToAgentMapper for pattern-based skills
24
26
  4. Combine both sources into unified set
25
27
 
28
+ DEPLOYMENT TRACKING:
29
+ 1. Track which skills were deployed by claude-mpm in index file
30
+ 2. Update index after each deployment operation
31
+ 3. Clean up orphaned skills no longer referenced by agents
32
+
26
33
  References:
27
34
  - Feature: Progressive skills discovery (#117)
28
35
  - Service: SkillToAgentMapper (skill_to_agent_mapper.py)
29
36
  """
30
37
 
38
+ import json
31
39
  import re
40
+ from datetime import datetime, timezone
32
41
  from pathlib import Path
33
- from typing import Any, Dict, List, Set
42
+ from typing import Any, Dict, List, Set, Tuple
34
43
 
35
44
  import yaml
36
45
 
@@ -39,6 +48,9 @@ from claude_mpm.services.skills.skill_to_agent_mapper import SkillToAgentMapper
39
48
 
40
49
  logger = get_logger(__name__)
41
50
 
51
+ # Deployment tracking index file
52
+ DEPLOYED_INDEX_FILE = ".mpm-deployed-skills.json"
53
+
42
54
 
43
55
  def parse_agent_frontmatter(agent_file: Path) -> Dict[str, Any]:
44
56
  """Parse YAML frontmatter from agent markdown file.
@@ -228,3 +240,465 @@ def get_required_skills_from_agents(agents_dir: Path) -> Set[str]:
228
240
  )
229
241
 
230
242
  return normalized_skills
243
+
244
+
245
+ # === Deployment Tracking Functions ===
246
+
247
+
248
+ def load_deployment_index(claude_skills_dir: Path) -> Dict[str, Any]:
249
+ """Load deployment tracking index from ~/.claude/skills/.
250
+
251
+ Args:
252
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
253
+
254
+ Returns:
255
+ Dict containing:
256
+ - deployed_skills: Dict mapping skill name to deployment metadata
257
+ - user_requested_skills: List of skill names manually requested by user
258
+ - last_sync: ISO timestamp of last sync operation
259
+
260
+ Example:
261
+ >>> index = load_deployment_index(Path.home() / ".claude" / "skills")
262
+ >>> print(f"Tracked skills: {len(index['deployed_skills'])}")
263
+ """
264
+ index_path = claude_skills_dir / DEPLOYED_INDEX_FILE
265
+
266
+ if not index_path.exists():
267
+ logger.debug(f"No deployment index found at {index_path}, creating new")
268
+ return {"deployed_skills": {}, "user_requested_skills": [], "last_sync": None}
269
+
270
+ try:
271
+ with open(index_path, encoding="utf-8") as f:
272
+ index = json.load(f)
273
+
274
+ # Ensure required keys exist
275
+ if "deployed_skills" not in index:
276
+ index["deployed_skills"] = {}
277
+ if "user_requested_skills" not in index:
278
+ index["user_requested_skills"] = []
279
+ if "last_sync" not in index:
280
+ index["last_sync"] = None
281
+
282
+ logger.debug(
283
+ f"Loaded deployment index: {len(index['deployed_skills'])} tracked skills, "
284
+ f"{len(index['user_requested_skills'])} user-requested"
285
+ )
286
+ return index
287
+
288
+ except (json.JSONDecodeError, OSError) as e:
289
+ logger.warning(f"Failed to load deployment index: {e}, creating new")
290
+ return {"deployed_skills": {}, "user_requested_skills": [], "last_sync": None}
291
+
292
+
293
+ def save_deployment_index(claude_skills_dir: Path, index: Dict[str, Any]) -> None:
294
+ """Save deployment tracking index to ~/.claude/skills/.
295
+
296
+ Args:
297
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
298
+ index: Index data to save
299
+
300
+ Example:
301
+ >>> index = {"deployed_skills": {...}, "last_sync": "2025-12-22T10:30:00Z"}
302
+ >>> save_deployment_index(Path.home() / ".claude" / "skills", index)
303
+ """
304
+ index_path = claude_skills_dir / DEPLOYED_INDEX_FILE
305
+
306
+ try:
307
+ # Ensure directory exists
308
+ claude_skills_dir.mkdir(parents=True, exist_ok=True)
309
+
310
+ with open(index_path, "w", encoding="utf-8") as f:
311
+ json.dump(index, f, indent=2, ensure_ascii=False)
312
+
313
+ logger.debug(f"Saved deployment index: {len(index['deployed_skills'])} skills")
314
+
315
+ except OSError as e:
316
+ logger.error(f"Failed to save deployment index: {e}")
317
+ raise
318
+
319
+
320
+ def track_deployed_skill(
321
+ claude_skills_dir: Path, skill_name: str, collection: str
322
+ ) -> None:
323
+ """Track a newly deployed skill in the deployment index.
324
+
325
+ Args:
326
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
327
+ skill_name: Name of deployed skill
328
+ collection: Collection name skill was deployed from
329
+
330
+ Example:
331
+ >>> track_deployed_skill(
332
+ ... Path.home() / ".claude" / "skills",
333
+ ... "systematic-debugging",
334
+ ... "claude-mpm-skills"
335
+ ... )
336
+ """
337
+ index = load_deployment_index(claude_skills_dir)
338
+
339
+ # Add skill to deployed_skills
340
+ index["deployed_skills"][skill_name] = {
341
+ "collection": collection,
342
+ "deployed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
343
+ }
344
+
345
+ # Update last_sync timestamp
346
+ index["last_sync"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
347
+
348
+ save_deployment_index(claude_skills_dir, index)
349
+ logger.debug(f"Tracked deployment: {skill_name} from {collection}")
350
+
351
+
352
+ def untrack_skill(claude_skills_dir: Path, skill_name: str) -> None:
353
+ """Remove skill from deployment tracking index.
354
+
355
+ Args:
356
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
357
+ skill_name: Name of skill to untrack
358
+
359
+ Example:
360
+ >>> untrack_skill(
361
+ ... Path.home() / ".claude" / "skills",
362
+ ... "old-skill"
363
+ ... )
364
+ """
365
+ index = load_deployment_index(claude_skills_dir)
366
+
367
+ if skill_name in index["deployed_skills"]:
368
+ del index["deployed_skills"][skill_name]
369
+ index["last_sync"] = (
370
+ datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
371
+ )
372
+ save_deployment_index(claude_skills_dir, index)
373
+ logger.debug(f"Untracked skill: {skill_name}")
374
+
375
+
376
+ def cleanup_orphan_skills(
377
+ claude_skills_dir: Path, required_skills: Set[str]
378
+ ) -> Dict[str, Any]:
379
+ """Remove skills deployed by claude-mpm but no longer referenced by agents.
380
+
381
+ This function:
382
+ 1. Loads deployment tracking index
383
+ 2. Identifies orphaned skills (tracked but not in required_skills AND not user-requested)
384
+ 3. Removes orphaned skill directories from ~/.claude/skills/
385
+ 4. Updates deployment index
386
+
387
+ User-requested skills are NEVER cleaned up as orphans - they are treated as required.
388
+
389
+ Args:
390
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
391
+ required_skills: Set of skill names currently required by agents
392
+
393
+ Returns:
394
+ Dict containing:
395
+ - removed_count: Number of skills removed
396
+ - removed_skills: List of removed skill names
397
+ - kept_count: Number of skills kept
398
+ - errors: List of error messages
399
+
400
+ Example:
401
+ >>> required = {"skill-a", "skill-b"}
402
+ >>> result = cleanup_orphan_skills(
403
+ ... Path.home() / ".claude" / "skills",
404
+ ... required
405
+ ... )
406
+ >>> print(f"Removed {result['removed_count']} orphaned skills")
407
+ """
408
+ import shutil
409
+
410
+ index = load_deployment_index(claude_skills_dir)
411
+ tracked_skills = set(index["deployed_skills"].keys())
412
+ user_requested = set(index.get("user_requested_skills", []))
413
+
414
+ # Find orphaned skills: tracked by mpm but not in required_skills AND not user-requested
415
+ # User-requested skills are treated as required and NEVER cleaned up
416
+ all_required = required_skills | user_requested
417
+ orphaned = tracked_skills - all_required
418
+
419
+ if not orphaned:
420
+ logger.info("No orphaned skills to remove")
421
+ return {
422
+ "removed_count": 0,
423
+ "removed_skills": [],
424
+ "kept_count": len(tracked_skills),
425
+ "errors": [],
426
+ }
427
+
428
+ logger.info(
429
+ f"Found {len(orphaned)} orphaned skills (tracked but not required by agents)"
430
+ )
431
+
432
+ removed = []
433
+ errors = []
434
+
435
+ for skill_name in orphaned:
436
+ skill_dir = claude_skills_dir / skill_name
437
+
438
+ # Remove skill directory if it exists
439
+ if skill_dir.exists():
440
+ try:
441
+ # Validate path is within claude_skills_dir (security)
442
+ skill_dir.resolve().relative_to(claude_skills_dir.resolve())
443
+
444
+ # Remove directory
445
+ if skill_dir.is_symlink():
446
+ logger.debug(f"Removing symlink: {skill_dir}")
447
+ skill_dir.unlink()
448
+ else:
449
+ shutil.rmtree(skill_dir)
450
+
451
+ removed.append(skill_name)
452
+ logger.info(f"Removed orphaned skill: {skill_name}")
453
+
454
+ except ValueError:
455
+ error_msg = f"Path traversal attempt detected: {skill_dir}"
456
+ logger.error(error_msg)
457
+ errors.append(error_msg)
458
+ continue
459
+ except Exception as e:
460
+ error_msg = f"Failed to remove {skill_name}: {e}"
461
+ logger.error(error_msg)
462
+ errors.append(error_msg)
463
+ continue
464
+
465
+ # Remove from tracking index
466
+ untrack_skill(claude_skills_dir, skill_name)
467
+
468
+ kept_count = len(tracked_skills) - len(removed)
469
+
470
+ logger.info(
471
+ f"Cleanup complete: removed {len(removed)} skills, kept {kept_count} skills"
472
+ )
473
+
474
+ return {
475
+ "removed_count": len(removed),
476
+ "removed_skills": removed,
477
+ "kept_count": kept_count,
478
+ "errors": errors,
479
+ }
480
+
481
+
482
+ # === Configuration Management Functions ===
483
+
484
+
485
+ def save_agent_skills_to_config(skills: List[str], config_path: Path) -> None:
486
+ """Save agent-scanned skills to configuration.yaml under skills.agent_referenced.
487
+
488
+ Args:
489
+ skills: List of skill names scanned from deployed agents
490
+ config_path: Path to configuration.yaml file
491
+
492
+ Example:
493
+ >>> skills = ["systematic-debugging", "typescript-core"]
494
+ >>> save_agent_skills_to_config(skills, Path(".claude-mpm/configuration.yaml"))
495
+ """
496
+ import yaml
497
+
498
+ try:
499
+ # Load existing configuration (or create empty dict)
500
+ if config_path.exists():
501
+ with open(config_path, encoding="utf-8") as f:
502
+ config = yaml.safe_load(f) or {}
503
+ else:
504
+ config = {}
505
+
506
+ # Ensure skills section exists
507
+ if "skills" not in config:
508
+ config["skills"] = {}
509
+
510
+ # Update agent_referenced skills (sorted for consistency)
511
+ config["skills"]["agent_referenced"] = sorted(skills)
512
+
513
+ # Ensure user_defined exists (but don't overwrite if set)
514
+ if "user_defined" not in config["skills"]:
515
+ config["skills"]["user_defined"] = []
516
+
517
+ # Save configuration
518
+ config_path.parent.mkdir(parents=True, exist_ok=True)
519
+ with open(config_path, "w", encoding="utf-8") as f:
520
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
521
+
522
+ logger.info(
523
+ f"Saved {len(skills)} agent-referenced skills to configuration.yaml"
524
+ )
525
+
526
+ except Exception as e:
527
+ logger.error(f"Failed to save agent skills to config: {e}")
528
+ raise
529
+
530
+
531
+ def get_skills_to_deploy(config_path: Path) -> Tuple[List[str], str]:
532
+ """Resolve which skills to deploy based on configuration priority.
533
+
534
+ Returns (skills_list, source) where source is 'user_defined' or 'agent_referenced'.
535
+
536
+ Logic:
537
+ - If config.skills.user_defined is non-empty → return (user_defined, 'user_defined')
538
+ - Otherwise → return (agent_referenced, 'agent_referenced')
539
+
540
+ Args:
541
+ config_path: Path to configuration.yaml file
542
+
543
+ Returns:
544
+ Tuple of (skills list, source string)
545
+
546
+ Example:
547
+ >>> skills, source = get_skills_to_deploy(Path(".claude-mpm/configuration.yaml"))
548
+ >>> print(f"Deploy {len(skills)} skills from {source}")
549
+ """
550
+ import yaml
551
+
552
+ try:
553
+ # Load configuration
554
+ if not config_path.exists():
555
+ logger.warning(f"Configuration file not found: {config_path}")
556
+ return ([], "agent_referenced")
557
+
558
+ with open(config_path, encoding="utf-8") as f:
559
+ config = yaml.safe_load(f) or {}
560
+
561
+ skills_config = config.get("skills", {})
562
+ user_defined = skills_config.get("user_defined", [])
563
+ agent_referenced = skills_config.get("agent_referenced", [])
564
+
565
+ # Priority: user_defined if non-empty, otherwise agent_referenced
566
+ if user_defined:
567
+ logger.info(
568
+ f"Using {len(user_defined)} user-defined skills from configuration"
569
+ )
570
+ return (user_defined, "user_defined")
571
+ logger.info(
572
+ f"Using {len(agent_referenced)} agent-referenced skills from configuration"
573
+ )
574
+ return (agent_referenced, "agent_referenced")
575
+
576
+ except Exception as e:
577
+ logger.error(f"Failed to load skills from config: {e}")
578
+ return ([], "agent_referenced")
579
+
580
+
581
+ # === User-Requested Skills Management ===
582
+
583
+
584
+ def get_user_requested_skills(claude_skills_dir: Path) -> List[str]:
585
+ """Get list of user-requested skills.
586
+
587
+ Args:
588
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
589
+
590
+ Returns:
591
+ List of skill names manually requested by user
592
+
593
+ Example:
594
+ >>> skills = get_user_requested_skills(Path.home() / ".claude" / "skills")
595
+ >>> print(f"User requested {len(skills)} skills")
596
+ """
597
+ index = load_deployment_index(claude_skills_dir)
598
+ return index.get("user_requested_skills", [])
599
+
600
+
601
+ def add_user_requested_skill(skill_name: str, claude_skills_dir: Path) -> bool:
602
+ """Add a skill to user_requested_skills list.
603
+
604
+ This function:
605
+ 1. Loads deployment index
606
+ 2. Adds skill name to user_requested_skills (if not already present)
607
+ 3. Saves updated index
608
+ 4. Returns success status
609
+
610
+ Note: This function does NOT deploy the skill, it only marks it as user-requested.
611
+ Use this in conjunction with skill deployment functions.
612
+
613
+ Args:
614
+ skill_name: Name of skill to mark as user-requested
615
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
616
+
617
+ Returns:
618
+ True if skill was added, False if already present
619
+
620
+ Example:
621
+ >>> added = add_user_requested_skill(
622
+ ... "django-framework",
623
+ ... Path.home() / ".claude" / "skills"
624
+ ... )
625
+ >>> print(f"Skill added: {added}")
626
+ """
627
+ index = load_deployment_index(claude_skills_dir)
628
+ user_requested = index.get("user_requested_skills", [])
629
+
630
+ if skill_name in user_requested:
631
+ logger.debug(f"Skill {skill_name} already in user_requested_skills")
632
+ return False
633
+
634
+ user_requested.append(skill_name)
635
+ index["user_requested_skills"] = user_requested
636
+ index["last_sync"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
637
+
638
+ save_deployment_index(claude_skills_dir, index)
639
+ logger.info(f"Added {skill_name} to user_requested_skills")
640
+ return True
641
+
642
+
643
+ def remove_user_requested_skill(skill_name: str, claude_skills_dir: Path) -> bool:
644
+ """Remove a skill from user_requested_skills list.
645
+
646
+ This function:
647
+ 1. Loads deployment index
648
+ 2. Removes skill name from user_requested_skills
649
+ 3. Saves updated index
650
+ 4. Returns success status
651
+
652
+ Note: This function does NOT remove the deployed skill directory.
653
+ It only removes the skill from user_requested_skills, making it eligible
654
+ for cleanup during orphan removal.
655
+
656
+ Args:
657
+ skill_name: Name of skill to remove from user_requested_skills
658
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
659
+
660
+ Returns:
661
+ True if skill was removed, False if not present
662
+
663
+ Example:
664
+ >>> removed = remove_user_requested_skill(
665
+ ... "django-framework",
666
+ ... Path.home() / ".claude" / "skills"
667
+ ... )
668
+ >>> print(f"Skill removed: {removed}")
669
+ """
670
+ index = load_deployment_index(claude_skills_dir)
671
+ user_requested = index.get("user_requested_skills", [])
672
+
673
+ if skill_name not in user_requested:
674
+ logger.debug(f"Skill {skill_name} not in user_requested_skills")
675
+ return False
676
+
677
+ user_requested.remove(skill_name)
678
+ index["user_requested_skills"] = user_requested
679
+ index["last_sync"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
680
+
681
+ save_deployment_index(claude_skills_dir, index)
682
+ logger.info(f"Removed {skill_name} from user_requested_skills")
683
+ return True
684
+
685
+
686
+ def is_user_requested_skill(skill_name: str, claude_skills_dir: Path) -> bool:
687
+ """Check if a skill is in the user_requested_skills list.
688
+
689
+ Args:
690
+ skill_name: Name of skill to check
691
+ claude_skills_dir: Path to Claude skills directory (~/.claude/skills/)
692
+
693
+ Returns:
694
+ True if skill is user-requested, False otherwise
695
+
696
+ Example:
697
+ >>> is_requested = is_user_requested_skill(
698
+ ... "django-framework",
699
+ ... Path.home() / ".claude" / "skills"
700
+ ... )
701
+ >>> print(f"User requested: {is_requested}")
702
+ """
703
+ user_requested = get_user_requested_skills(claude_skills_dir)
704
+ return skill_name in user_requested
@@ -224,6 +224,13 @@ class SkillsDeployerService(LoggerMixin):
224
224
  skipped = []
225
225
  errors = []
226
226
 
227
+ # Extract skill names for cleanup (needed regardless of deployment outcome)
228
+ filtered_skills_names = [
229
+ skill["name"]
230
+ for skill in filtered_skills
231
+ if isinstance(skill, dict) and "name" in skill
232
+ ]
233
+
227
234
  for skill in filtered_skills:
228
235
  try:
229
236
  # Validate skill is a dictionary
@@ -232,7 +239,9 @@ class SkillsDeployerService(LoggerMixin):
232
239
  errors.append(f"Invalid skill format: {skill}")
233
240
  continue
234
241
 
235
- result = self._deploy_skill(skill, skills_data["temp_dir"], force=force)
242
+ result = self._deploy_skill(
243
+ skill, skills_data["temp_dir"], collection_name, force=force
244
+ )
236
245
  if result["deployed"]:
237
246
  deployed.append(skill["name"])
238
247
  elif result["skipped"]:
@@ -248,10 +257,33 @@ class SkillsDeployerService(LoggerMixin):
248
257
  self.logger.error(f"Failed to deploy {skill_name}: {e}")
249
258
  errors.append(f"{skill_name}: {e}")
250
259
 
251
- # Step 5: Cleanup
260
+ # Step 5: Cleanup orphaned skills (if selective mode enabled)
261
+ cleanup_result = {"removed_count": 0, "removed_skills": []}
262
+ if selective and len(deployed) > 0:
263
+ # Get the set of skills that should remain deployed
264
+ # This is the union of what we just deployed and what was already there
265
+ try:
266
+ from claude_mpm.services.skills.selective_skill_deployer import (
267
+ cleanup_orphan_skills,
268
+ )
269
+
270
+ # Only cleanup if we're in selective mode
271
+ cleanup_result = cleanup_orphan_skills(
272
+ self.CLAUDE_SKILLS_DIR, set(filtered_skills_names)
273
+ )
274
+
275
+ if cleanup_result["removed_count"] > 0:
276
+ self.logger.info(
277
+ f"Removed {cleanup_result['removed_count']} orphaned skills: "
278
+ f"{', '.join(cleanup_result['removed_skills'])}"
279
+ )
280
+ except Exception as e:
281
+ self.logger.warning(f"Failed to cleanup orphaned skills: {e}")
282
+
283
+ # Step 6: Cleanup temp directory
252
284
  self._cleanup(skills_data["temp_dir"])
253
285
 
254
- # Step 6: Check if Claude Code restart needed
286
+ # Step 7: Check if Claude Code restart needed
255
287
  restart_required = len(deployed) > 0
256
288
  restart_instructions = ""
257
289
 
@@ -275,7 +307,8 @@ class SkillsDeployerService(LoggerMixin):
275
307
 
276
308
  self.logger.info(
277
309
  f"Deployment complete: {len(deployed)} deployed, "
278
- f"{len(skipped)} skipped, {len(errors)} errors"
310
+ f"{len(skipped)} skipped, {len(errors)} errors, "
311
+ f"{cleanup_result['removed_count']} orphaned skills removed"
279
312
  )
280
313
 
281
314
  return {
@@ -289,6 +322,7 @@ class SkillsDeployerService(LoggerMixin):
289
322
  "collection": collection_name,
290
323
  "selective_mode": selective,
291
324
  "total_available": total_available,
325
+ "cleanup": cleanup_result,
292
326
  }
293
327
 
294
328
  def list_available_skills(self, collection: Optional[str] = None) -> Dict:
@@ -473,6 +507,13 @@ class SkillsDeployerService(LoggerMixin):
473
507
  removed.append(skill_name)
474
508
  self.logger.info(f"Removed skill: {skill_name}")
475
509
 
510
+ # Untrack skill from deployment index
511
+ from claude_mpm.services.skills.selective_skill_deployer import (
512
+ untrack_skill,
513
+ )
514
+
515
+ untrack_skill(self.CLAUDE_SKILLS_DIR, skill_name)
516
+
476
517
  except Exception as e:
477
518
  self.logger.error(f"Failed to remove {skill_name}: {e}")
478
519
  errors.append(f"{skill_name}: {e}")
@@ -738,17 +779,25 @@ class SkillsDeployerService(LoggerMixin):
738
779
  return filtered
739
780
 
740
781
  def _deploy_skill(
741
- self, skill: Dict, collection_dir: Path, force: bool = False
782
+ self,
783
+ skill: Dict,
784
+ collection_dir: Path,
785
+ collection_name: str,
786
+ force: bool = False,
742
787
  ) -> Dict:
743
- """Deploy a single skill to ~/.claude/skills/.
788
+ """Deploy a single skill to ~/.claude/skills/ and track deployment.
744
789
 
745
790
  NOTE: With multi-collection support, skills are now stored in collection
746
791
  subdirectories. This method creates symlinks or copies to maintain the
747
792
  flat structure that Claude Code expects in ~/.claude/skills/.
748
793
 
794
+ Additionally tracks deployed skills in .mpm-deployed-skills.json index
795
+ for orphan cleanup functionality.
796
+
749
797
  Args:
750
798
  skill: Skill metadata dict
751
799
  collection_dir: Collection directory containing skills
800
+ collection_name: Name of collection (for tracking)
752
801
  force: Overwrite if already exists
753
802
 
754
803
  Returns:
@@ -838,6 +887,13 @@ class SkillsDeployerService(LoggerMixin):
838
887
  # NOTE: We use copy instead of symlink to maintain Claude Code compatibility
839
888
  shutil.copytree(source_dir, target_dir)
840
889
 
890
+ # Track deployment in index
891
+ from claude_mpm.services.skills.selective_skill_deployer import (
892
+ track_deployed_skill,
893
+ )
894
+
895
+ track_deployed_skill(self.CLAUDE_SKILLS_DIR, skill_name, collection_name)
896
+
841
897
  self.logger.debug(
842
898
  f"Deployed {skill_name} from {source_dir} to {target_dir}"
843
899
  )
@@ -152,6 +152,7 @@ class DashboardServer(SocketIOServiceInterface):
152
152
 
153
153
  # Register handlers for all events we want to relay from monitor to dashboard
154
154
  relay_events = [
155
+ "claude_event", # Tool events from Claude Code hooks
155
156
  "session_started",
156
157
  "session_ended",
157
158
  "claude_status",