refactor: PM Agent complete independence from external MCP servers (#439)

* refactor: PM Agent complete independence from external MCP servers

## Summary
Implement graceful degradation to ensure PM Agent operates fully without
any MCP server dependencies. MCP servers now serve as optional enhancements
rather than required components.

## Changes

### Responsibility Separation (NEW)
- **PM Agent**: Development workflow orchestration (PDCA cycle, task management)
- **mindbase**: Memory management (long-term, freshness, error learning)
- **Built-in memory**: Session-internal context (volatile)

### 3-Layer Memory Architecture with Fallbacks
1. **Built-in Memory** [OPTIONAL]: Session context via MCP memory server
2. **mindbase** [OPTIONAL]: Long-term semantic search via airis-mcp-gateway
3. **Local Files** [ALWAYS]: Core functionality in docs/memory/

### Graceful Degradation Implementation
- All MCP operations marked with [ALWAYS] or [OPTIONAL]
- Explicit IF/ELSE fallback logic for every MCP call
- Dual storage: Always write to local files + optionally to mindbase
- Smart lookup: Semantic search (if available) → Text search (always works)

### Key Fallback Strategies

**Session Start**:
- mindbase available: search_conversations() for semantic context
- mindbase unavailable: Grep docs/memory/*.jsonl for text-based lookup

**Error Detection**:
- mindbase available: Semantic search for similar past errors
- mindbase unavailable: Grep docs/mistakes/ + solutions_learned.jsonl

**Knowledge Capture**:
- Always: echo >> docs/memory/patterns_learned.jsonl (persistent)
- Optional: mindbase.store() for semantic search enhancement

## Benefits
-  Zero external dependencies (100% functionality without MCP)
-  Enhanced capabilities when MCPs available (semantic search, freshness)
-  No functionality loss, only reduced search intelligence
-  Transparent degradation (no error messages, automatic fallback)

## Related Research
- Serena MCP investigation: Exposes tools (not resources), memory = markdown files
- mindbase superiority: PostgreSQL + pgvector > Serena memory features
- Best practices alignment: /Users/kazuki/github/airis-mcp-gateway/docs/mcp-best-practices.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: add PR template and pre-commit config

- Add structured PR template with Git workflow checklist
- Add pre-commit hooks for secret detection and Conventional Commits
- Enforce code quality gates (YAML/JSON/Markdown lint, shellcheck)

NOTE: Execute pre-commit inside Docker container to avoid host pollution:
  docker compose exec workspace uv tool install pre-commit
  docker compose exec workspace pre-commit run --all-files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update PM Agent context with token efficiency architecture

- Add Layer 0 Bootstrap (150 tokens, 95% reduction)
- Document Intent Classification System (5 complexity levels)
- Add Progressive Loading strategy (5-layer)
- Document mindbase integration incentive (38% savings)
- Update with 2025-10-17 redesign details

* refactor: PM Agent command with progressive loading

- Replace auto-loading with User Request First philosophy
- Add 5-layer progressive context loading
- Implement intent classification system
- Add workflow metrics collection (.jsonl)
- Document graceful degradation strategy

* fix: installer improvements

Update installer logic for better reliability

* docs: add comprehensive development documentation

- Add architecture overview
- Add PM Agent improvements analysis
- Add parallel execution architecture
- Add CLI install improvements
- Add code style guide
- Add project overview
- Add install process analysis

* docs: add research documentation

Add LLM agent token efficiency research and analysis

* docs: add suggested commands reference

* docs: add session logs and testing documentation

- Add session analysis logs
- Add testing documentation

* feat: migrate CLI to typer + rich for modern UX

## What Changed

### New CLI Architecture (typer + rich)
- Created `superclaude/cli/` module with modern typer-based CLI
- Replaced custom UI utilities with rich native features
- Added type-safe command structure with automatic validation

### Commands Implemented
- **install**: Interactive installation with rich UI (progress, panels)
- **doctor**: System diagnostics with rich table output
- **config**: API key management with format validation

### Technical Improvements
- Dependencies: Added typer>=0.9.0, rich>=13.0.0, click>=8.0.0
- Entry Point: Updated pyproject.toml to use `superclaude.cli.app:cli_main`
- Tests: Added comprehensive smoke tests (11 passed)

### User Experience Enhancements
- Rich formatted help messages with panels and tables
- Automatic input validation with retry loops
- Clear error messages with actionable suggestions
- Non-interactive mode support for CI/CD

## Testing

```bash
uv run superclaude --help     # ✓ Works
uv run superclaude doctor     # ✓ Rich table output
uv run superclaude config show # ✓ API key management
pytest tests/test_cli_smoke.py # ✓ 11 passed, 1 skipped
```

## Migration Path

-  P0: Foundation complete (typer + rich + smoke tests)
- 🔜 P1: Pydantic validation models (next sprint)
- 🔜 P2: Enhanced error messages (next sprint)
- 🔜 P3: API key retry loops (next sprint)

## Performance Impact

- **Code Reduction**: Prepared for -300 lines (custom UI → rich)
- **Type Safety**: Automatic validation from type hints
- **Maintainability**: Framework primitives vs custom code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: consolidate documentation directories

Merged claudedocs/ into docs/research/ for consistent documentation structure.

Changes:
- Moved all claudedocs/*.md files to docs/research/
- Updated all path references in documentation (EN/KR)
- Updated RULES.md and research.md command templates
- Removed claudedocs/ directory
- Removed ClaudeDocs/ from .gitignore

Benefits:
- Single source of truth for all research reports
- PEP8-compliant lowercase directory naming
- Clearer documentation organization
- Prevents future claudedocs/ directory creation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* perf: reduce /sc:pm command output from 1652 to 15 lines

- Remove 1637 lines of documentation from command file
- Keep only minimal bootstrap message
- 99% token reduction on command execution
- Detailed specs remain in superclaude/agents/pm-agent.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* perf: split PM Agent into execution workflows and guide

- Reduce pm-agent.md from 735 to 429 lines (42% reduction)
- Move philosophy/examples to docs/agents/pm-agent-guide.md
- Execution workflows (PDCA, file ops) stay in pm-agent.md
- Guide (examples, quality standards) read once when needed

Token savings:
- Agent loading: ~6K → ~3.5K tokens (42% reduction)
- Total with pm.md: 71% overall reduction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: consolidate PM Agent optimization and pending changes

PM Agent optimization (already committed separately):
- superclaude/commands/pm.md: 1652→14 lines
- superclaude/agents/pm-agent.md: 735→429 lines
- docs/agents/pm-agent-guide.md: new guide file

Other pending changes:
- setup: framework_docs, mcp, logger, remove ui.py
- superclaude: __main__, cli/app, cli/commands/install
- tests: test_ui updates
- scripts: workflow metrics analysis tools
- docs/memory: session state updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: simplify MCP installer to unified gateway with legacy mode

## Changes

### MCP Component (setup/components/mcp.py)
- Simplified to single airis-mcp-gateway by default
- Added legacy mode for individual official servers (sequential-thinking, context7, magic, playwright)
- Dynamic prerequisites based on mode:
  - Default: uv + claude CLI only
  - Legacy: node (18+) + npm + claude CLI
- Removed redundant server definitions

### CLI Integration
- Added --legacy flag to setup/cli/commands/install.py
- Added --legacy flag to superclaude/cli/commands/install.py
- Config passes legacy_mode to component installer

## Benefits
-  Simpler: 1 gateway vs 9+ individual servers
-  Lighter: No Node.js/npm required (default mode)
-  Unified: All tools in one gateway (sequential-thinking, context7, magic, playwright, serena, morphllm, tavily, chrome-devtools, git, puppeteer)
-  Flexible: --legacy flag for official servers if needed

## Usage
```bash
superclaude install              # Default: airis-mcp-gateway (推奨)
superclaude install --legacy     # Legacy: individual official servers
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: rename CoreComponent to FrameworkDocsComponent and add PM token tracking

## Changes

### Component Renaming (setup/components/)
- Renamed CoreComponent → FrameworkDocsComponent for clarity
- Updated all imports in __init__.py, agents.py, commands.py, mcp_docs.py, modes.py
- Better reflects the actual purpose (framework documentation files)

### PM Agent Enhancement (superclaude/commands/pm.md)
- Added token usage tracking instructions
- PM Agent now reports:
  1. Current token usage from system warnings
  2. Percentage used (e.g., "27% used" for 54K/200K)
  3. Status zone: 🟢 <75% | 🟡 75-85% | 🔴 >85%
- Helps prevent token exhaustion during long sessions

### UI Utilities (setup/utils/ui.py)
- Added new UI utility module for installer
- Provides consistent user interface components

## Benefits
-  Clearer component naming (FrameworkDocs vs Core)
-  PM Agent token awareness for efficiency
-  Better visual feedback with status zones

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(pm-agent): minimize output verbosity (471→284 lines, 40% reduction)

**Problem**: PM Agent generated excessive output with redundant explanations
- "System Status Report" with decorative formatting
- Repeated "Common Tasks" lists user already knows
- Verbose session start/end protocols
- Duplicate file operations documentation

**Solution**: Compress without losing functionality
- Session Start: Reduced to symbol-only status (🟢 branch | nM nD | token%)
- Session End: Compressed to essential actions only
- File Operations: Consolidated from 2 sections to 1 line reference
- Self-Improvement: 5 phases → 1 unified workflow
- Output Rules: Explicit constraints to prevent Claude over-explanation

**Quality Preservation**:
-  All core functions retained (PDCA, memory, patterns, mistakes)
-  PARALLEL Read/Write preserved (performance critical)
-  Workflow unchanged (session lifecycle intact)
-  Added output constraints (prevents verbose generation)

**Reduction Method**:
- Deleted: Explanatory text, examples, redundant sections
- Retained: Action definitions, file paths, core workflows
- Added: Explicit output constraints to enforce minimalism

**Token Impact**: 40% reduction in agent documentation size
**Before**: Verbose multi-section report with task lists
**After**: Single line status: 🟢 integration | 15M 17D | 36%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: consolidate MCP integration to unified gateway

**Changes**:
- Remove individual MCP server docs (superclaude/mcp/*.md)
- Remove MCP server configs (superclaude/mcp/configs/*.json)
- Delete MCP docs component (setup/components/mcp_docs.py)
- Simplify installer (setup/core/installer.py)
- Update components for unified gateway approach

**Rationale**:
- Unified gateway (airis-mcp-gateway) provides all MCP servers
- Individual docs/configs no longer needed (managed centrally)
- Reduces maintenance burden and file count
- Simplifies installation process

**Files Removed**: 17 MCP files (docs + configs)
**Installer Changes**: Removed legacy MCP installation logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: update version and component metadata

- Bump version (pyproject.toml, setup/__init__.py)
- Update CLAUDE.md import service references
- Reflect component structure changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: kazuki <kazuki@kazukinoMacBook-Air.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
kazuki nakai
2025-10-17 09:13:06 +09:00
committed by GitHub
parent 5bc82dbe30
commit 882a0d8356
90 changed files with 12060 additions and 3773 deletions

View File

@@ -20,5 +20,5 @@ DATA_DIR = SETUP_DIR / "data"
# Import home directory detection for immutable distros
from .utils.paths import get_home_directory
# Installation target
DEFAULT_INSTALL_DIR = get_home_directory() / ".claude"
# Installation target - SuperClaude components installed in subdirectory
DEFAULT_INSTALL_DIR = get_home_directory() / ".claude" / "superclaude"

View File

@@ -80,6 +80,12 @@ Examples:
help="Run system diagnostics and show installation help",
)
parser.add_argument(
"--legacy",
action="store_true",
help="Use legacy mode: install individual official MCP servers instead of unified gateway",
)
return parser
@@ -132,12 +138,12 @@ def get_components_to_install(
# Explicit components specified
if args.components:
if "all" in args.components:
components = ["core", "commands", "agents", "modes", "mcp", "mcp_docs"]
components = ["framework_docs", "commands", "agents", "modes", "mcp"]
else:
components = args.components
# If mcp or mcp_docs is specified non-interactively, we should still ask which servers to install.
if "mcp" in components or "mcp_docs" in components:
# If mcp is specified, handle MCP server selection
if "mcp" in components and not args.yes:
selected_servers = select_mcp_servers(registry)
if not hasattr(config_manager, "_installation_context"):
config_manager._installation_context = {}
@@ -145,26 +151,16 @@ def get_components_to_install(
selected_servers
)
# If the user selected some servers, ensure both mcp and mcp_docs are included
# If the user selected some servers, ensure mcp is included
if selected_servers:
if "mcp" not in components:
components.append("mcp")
logger.debug(
f"Auto-added 'mcp' component for selected servers: {selected_servers}"
)
if "mcp_docs" not in components:
components.append("mcp_docs")
logger.debug(
f"Auto-added 'mcp_docs' component for selected servers: {selected_servers}"
)
logger.info(f"Final components to install: {components}")
# If mcp_docs was explicitly requested but no servers selected, allow auto-detection
elif not selected_servers and "mcp_docs" in components:
logger.info("mcp_docs component will auto-detect existing MCP servers")
logger.info("Documentation will be installed for any detected servers")
return components
# Interactive two-stage selection
@@ -221,7 +217,7 @@ def select_mcp_servers(registry: ComponentRegistry) -> List[str]:
try:
# Get MCP component to access server list
mcp_instance = registry.get_component_instance(
"mcp", get_home_directory() / ".claude"
"mcp", DEFAULT_INSTALL_DIR
)
if not mcp_instance or not hasattr(mcp_instance, "mcp_servers"):
logger.error("Could not access MCP server information")
@@ -306,7 +302,7 @@ def select_framework_components(
try:
# Framework components (excluding MCP-related ones)
framework_components = ["core", "modes", "commands", "agents"]
framework_components = ["framework_docs", "modes", "commands", "agents"]
# Create component menu
component_options = []
@@ -319,16 +315,7 @@ def select_framework_components(
component_options.append(f"{component_name} - {description}")
component_info[component_name] = metadata
# Add MCP documentation option
if selected_mcp_servers:
mcp_docs_desc = f"MCP documentation for {', '.join(selected_mcp_servers)} (auto-selected)"
component_options.append(f"mcp_docs - {mcp_docs_desc}")
auto_selected_mcp_docs = True
else:
component_options.append(
"mcp_docs - MCP server documentation (none selected)"
)
auto_selected_mcp_docs = False
# MCP documentation is integrated into airis-mcp-gateway, no separate component needed
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*51}{Colors.RESET}")
print(
@@ -347,26 +334,17 @@ def select_framework_components(
selections = menu.display()
if not selections:
# Default to core if nothing selected
logger.info("No components selected, defaulting to core")
selected_components = ["core"]
# Default to framework_docs if nothing selected
logger.info("No components selected, defaulting to framework_docs")
selected_components = ["framework_docs"]
else:
selected_components = []
all_components = framework_components + ["mcp_docs"]
all_components = framework_components
for i in selections:
if i < len(all_components):
selected_components.append(all_components[i])
# Auto-select MCP docs if not explicitly deselected and we have MCP servers
if auto_selected_mcp_docs and "mcp_docs" not in selected_components:
# Check if user explicitly deselected it
mcp_docs_index = len(framework_components) # Index of mcp_docs in the menu
if mcp_docs_index not in selections:
# User didn't select it, but we auto-select it
selected_components.append("mcp_docs")
logger.info("Auto-selected MCP documentation for configured servers")
# Always include MCP component if servers were selected
if selected_mcp_servers and "mcp" not in selected_components:
selected_components.append("mcp")
@@ -376,7 +354,7 @@ def select_framework_components(
except Exception as e:
logger.error(f"Error in framework component selection: {e}")
return ["core"] # Fallback to core
return ["framework_docs"] # Fallback to framework_docs
def interactive_component_selection(
@@ -564,6 +542,7 @@ def perform_installation(
"force": args.force,
"backup": not args.no_backup,
"dry_run": args.dry_run,
"legacy_mode": getattr(args, "legacy", False),
"selected_mcp_servers": getattr(
config_manager, "_installation_context", {}
).get("selected_mcp_servers", []),
@@ -594,9 +573,6 @@ def perform_installation(
if summary["installed"]:
logger.info(f"Installed components: {', '.join(summary['installed'])}")
if summary["backup_path"]:
logger.info(f"Backup created: {summary['backup_path']}")
else:
logger.error(
f"Installation completed with errors in {duration:.1f} seconds"

View File

@@ -79,14 +79,6 @@ def verify_superclaude_file(file_path: Path, component: str) -> bool:
"MODE_Task_Management.md",
"MODE_Token_Efficiency.md",
],
"mcp_docs": [
"MCP_Context7.md",
"MCP_Sequential.md",
"MCP_Magic.md",
"MCP_Playwright.md",
"MCP_Morphllm.md",
"MCP_Serena.md",
],
}
# For commands component, verify it's in the sc/ subdirectory
@@ -427,8 +419,7 @@ def _custom_component_selection(
"core": "Core Framework Files (CLAUDE.md, FLAGS.md, PRINCIPLES.md, etc.)",
"commands": "superclaude Commands (commands/sc/*.md)",
"agents": "Specialized Agents (agents/*.md)",
"mcp": "MCP Server Configurations",
"mcp_docs": "MCP Documentation",
"mcp": "MCP Server Configurations (airis-mcp-gateway)",
"modes": "superclaude Modes",
}
@@ -568,9 +559,8 @@ def display_component_details(component: str, info: Dict[str, Any]) -> Dict[str,
},
"mcp": {
"files": "MCP server configurations in .claude.json",
"description": "MCP server configurations",
"description": "MCP server configurations (airis-mcp-gateway)",
},
"mcp_docs": {"files": "MCP/*.md", "description": "MCP documentation files"},
"modes": {"files": "MODE_*.md", "description": "superclaude operational modes"},
}

View File

@@ -389,9 +389,6 @@ def perform_update(
if summary.get("updated"):
logger.info(f"Updated components: {', '.join(summary['updated'])}")
if summary.get("backup_path"):
logger.info(f"Backup created: {summary['backup_path']}")
else:
logger.error(f"Update completed with errors in {duration:.1f} seconds")

View File

@@ -1,17 +1,15 @@
"""Component implementations for SuperClaude installation system"""
from .core import CoreComponent
from .framework_docs import FrameworkDocsComponent
from .commands import CommandsComponent
from .mcp import MCPComponent
from .agents import AgentsComponent
from .modes import ModesComponent
from .mcp_docs import MCPDocsComponent
__all__ = [
"CoreComponent",
"FrameworkDocsComponent",
"CommandsComponent",
"MCPComponent",
"AgentsComponent",
"ModesComponent",
"MCPDocsComponent",
]

View File

@@ -25,6 +25,13 @@ class AgentsComponent(Component):
"category": "agents",
}
def is_reinstallable(self) -> bool:
"""
Agents should always be synced to latest version.
SuperClaude agent files always overwrite existing files.
"""
return True
def get_metadata_modifications(self) -> Dict[str, Any]:
"""Get metadata modifications for agents"""
return {
@@ -64,14 +71,14 @@ class AgentsComponent(Component):
self.settings_manager.update_metadata(metadata_mods)
self.logger.info("Updated metadata with agents configuration")
# Add component registration
# Add component registration (with file list for sync)
self.settings_manager.add_component_registration(
"agents",
{
"version": __version__,
"category": "agents",
"agents_count": len(self.component_files),
"agents_list": self.component_files,
"files": list(self.component_files), # Track for sync/deletion
},
)
@@ -126,60 +133,54 @@ class AgentsComponent(Component):
def get_dependencies(self) -> List[str]:
"""Get component dependencies"""
return ["core"]
return ["framework_docs"]
def update(self, config: Dict[str, Any]) -> bool:
"""Update agents component"""
"""
Sync agents component (overwrite + delete obsolete files).
No backup needed - SuperClaude source files are always authoritative.
"""
try:
self.logger.info("Updating SuperClaude agents component...")
self.logger.info("Syncing SuperClaude agents component...")
# Check current version
current_version = self.settings_manager.get_component_version("agents")
target_version = self.get_metadata()["version"]
if current_version == target_version:
self.logger.info(
f"Agents component already at version {target_version}"
)
return True
self.logger.info(
f"Updating agents component from {current_version} to {target_version}"
# Get previously installed files from metadata
metadata = self.settings_manager.load_metadata()
previous_files = set(
metadata.get("components", {}).get("agents", {}).get("files", [])
)
# Create backup of existing agents
backup_files = []
for filename in self.component_files:
# Get current files from source
current_files = set(self.component_files)
# Files to delete (were installed before, but no longer in source)
files_to_delete = previous_files - current_files
# Delete obsolete files
deleted_count = 0
for filename in files_to_delete:
file_path = self.install_component_subdir / filename
if file_path.exists():
backup_path = self.file_manager.backup_file(file_path)
if backup_path:
backup_files.append(backup_path)
self.logger.debug(f"Backed up agent: {filename}")
# Perform installation (will overwrite existing files)
if self._install(config):
self.logger.success(
f"Agents component updated to version {target_version}"
)
return True
else:
# Restore backups on failure
self.logger.error("Agents update failed, restoring backups...")
for backup_path in backup_files:
try:
original_path = (
self.install_component_subdir
/ backup_path.name.replace(".backup", "")
)
self.file_manager.copy_file(backup_path, original_path)
self.logger.debug(f"Restored {original_path.name}")
file_path.unlink()
deleted_count += 1
self.logger.info(f"Deleted obsolete agent: {filename}")
except Exception as e:
self.logger.warning(f"Could not restore {backup_path}: {e}")
return False
self.logger.warning(f"Could not delete {filename}: {e}")
# Install/overwrite current files (no backup)
success = self._install(config)
if success:
self.logger.success(
f"Agents synced: {len(current_files)} files, {deleted_count} obsolete files removed"
)
else:
self.logger.error("Agents sync failed")
return success
except Exception as e:
self.logger.exception(f"Unexpected error during agents update: {e}")
self.logger.exception(f"Unexpected error during agents sync: {e}")
return False
def _get_source_dir(self) -> Path:

View File

@@ -14,6 +14,15 @@ class CommandsComponent(Component):
def __init__(self, install_dir: Optional[Path] = None):
"""Initialize commands component"""
if install_dir is None:
install_dir = Path.home() / ".claude"
# Commands are installed directly to ~/.claude/commands/sc/
# not under superclaude/ subdirectory (Claude Code official location)
if "superclaude" in str(install_dir):
# ~/.claude/superclaude -> ~/.claude
install_dir = install_dir.parent
super().__init__(install_dir, Path("commands/sc"))
def get_metadata(self) -> Dict[str, str]:
@@ -25,6 +34,13 @@ class CommandsComponent(Component):
"category": "commands",
}
def is_reinstallable(self) -> bool:
"""
Commands should always be synced to latest version.
SuperClaude command files always overwrite existing files.
"""
return True
def get_metadata_modifications(self) -> Dict[str, Any]:
"""Get metadata modifications for commands component"""
return {
@@ -54,13 +70,14 @@ class CommandsComponent(Component):
self.settings_manager.update_metadata(metadata_mods)
self.logger.info("Updated metadata with commands configuration")
# Add component registration to metadata
# Add component registration to metadata (with file list for sync)
self.settings_manager.add_component_registration(
"commands",
{
"version": __version__,
"category": "commands",
"files_count": len(self.component_files),
"files": list(self.component_files), # Track for sync/deletion
},
)
self.logger.info("Updated metadata with commands component registration")
@@ -68,6 +85,16 @@ class CommandsComponent(Component):
self.logger.error(f"Failed to update metadata: {e}")
return False
# Clean up old commands directory in superclaude/ (from previous versions)
try:
old_superclaude_commands = Path.home() / ".claude" / "superclaude" / "commands"
if old_superclaude_commands.exists():
import shutil
shutil.rmtree(old_superclaude_commands)
self.logger.info("Removed old commands directory from superclaude/")
except Exception as e:
self.logger.debug(f"Could not remove old commands directory: {e}")
return True
def uninstall(self) -> bool:
@@ -153,69 +180,66 @@ class CommandsComponent(Component):
def get_dependencies(self) -> List[str]:
"""Get dependencies"""
return ["core"]
return ["framework_docs"]
def update(self, config: Dict[str, Any]) -> bool:
"""Update commands component"""
"""
Sync commands component (overwrite + delete obsolete files).
No backup needed - SuperClaude source files are always authoritative.
"""
try:
self.logger.info("Updating SuperClaude commands component...")
self.logger.info("Syncing SuperClaude commands component...")
# Check current version
current_version = self.settings_manager.get_component_version("commands")
target_version = self.get_metadata()["version"]
if current_version == target_version:
self.logger.info(
f"Commands component already at version {target_version}"
)
return True
self.logger.info(
f"Updating commands component from {current_version} to {target_version}"
# Get previously installed files from metadata
metadata = self.settings_manager.load_metadata()
previous_files = set(
metadata.get("components", {}).get("commands", {}).get("files", [])
)
# Create backup of existing command files
# Get current files from source
current_files = set(self.component_files)
# Files to delete (were installed before, but no longer in source)
files_to_delete = previous_files - current_files
# Delete obsolete files
deleted_count = 0
commands_dir = self.install_dir / "commands" / "sc"
backup_files = []
for filename in files_to_delete:
file_path = commands_dir / filename
if file_path.exists():
try:
file_path.unlink()
deleted_count += 1
self.logger.info(f"Deleted obsolete command: {filename}")
except Exception as e:
self.logger.warning(f"Could not delete {filename}: {e}")
if commands_dir.exists():
for filename in self.component_files:
file_path = commands_dir / filename
if file_path.exists():
backup_path = self.file_manager.backup_file(file_path)
if backup_path:
backup_files.append(backup_path)
self.logger.debug(f"Backed up {filename}")
# Perform installation (overwrites existing files)
# Install/overwrite current files (no backup)
success = self.install(config)
if success:
# Remove backup files on successful update
for backup_path in backup_files:
try:
backup_path.unlink()
except Exception:
pass # Ignore cleanup errors
# Update metadata with current file list
self.settings_manager.add_component_registration(
"commands",
{
"version": __version__,
"category": "commands",
"files_count": len(current_files),
"files": list(current_files), # Track installed files
},
)
self.logger.success(
f"Commands component updated to version {target_version}"
f"Commands synced: {len(current_files)} files, {deleted_count} obsolete files removed"
)
else:
# Restore from backup on failure
self.logger.warning("Update failed, restoring from backup...")
for backup_path in backup_files:
try:
original_path = backup_path.with_suffix("")
backup_path.rename(original_path)
self.logger.debug(f"Restored {original_path.name}")
except Exception as e:
self.logger.error(f"Could not restore {backup_path}: {e}")
self.logger.error("Commands sync failed")
return success
except Exception as e:
self.logger.exception(f"Unexpected error during commands update: {e}")
self.logger.exception(f"Unexpected error during commands sync: {e}")
return False
def validate_installation(self) -> Tuple[bool, List[str]]:

View File

@@ -1,5 +1,6 @@
"""
Core component for SuperClaude framework files installation
Framework documentation component for SuperClaude
Manages core framework documentation files (CLAUDE.md, FLAGS.md, PRINCIPLES.md, etc.)
"""
from typing import Dict, List, Tuple, Optional, Any
@@ -11,22 +12,29 @@ from ..services.claude_md import CLAUDEMdService
from setup import __version__
class CoreComponent(Component):
"""Core SuperClaude framework files component"""
class FrameworkDocsComponent(Component):
"""SuperClaude framework documentation files component"""
def __init__(self, install_dir: Optional[Path] = None):
"""Initialize core component"""
"""Initialize framework docs component"""
super().__init__(install_dir)
def get_metadata(self) -> Dict[str, str]:
"""Get component metadata"""
return {
"name": "core",
"name": "framework_docs",
"version": __version__,
"description": "SuperClaude framework documentation and core files",
"category": "core",
"description": "SuperClaude framework documentation (CLAUDE.md, FLAGS.md, PRINCIPLES.md, RULES.md, etc.)",
"category": "documentation",
}
def is_reinstallable(self) -> bool:
"""
Framework docs should always be updated to latest version.
SuperClaude-related documentation should always overwrite existing files.
"""
return True
def get_metadata_modifications(self) -> Dict[str, Any]:
"""Get metadata modifications for SuperClaude"""
return {
@@ -35,7 +43,7 @@ class CoreComponent(Component):
"name": "superclaude",
"description": "AI-enhanced development framework for Claude Code",
"installation_type": "global",
"components": ["core"],
"components": ["framework_docs"],
},
"superclaude": {
"enabled": True,
@@ -46,8 +54,8 @@ class CoreComponent(Component):
}
def _install(self, config: Dict[str, Any]) -> bool:
"""Install core component"""
self.logger.info("Installing SuperClaude core framework files...")
"""Install framework docs component"""
self.logger.info("Installing SuperClaude framework documentation...")
return super()._install(config)
@@ -58,17 +66,18 @@ class CoreComponent(Component):
self.settings_manager.update_metadata(metadata_mods)
self.logger.info("Updated metadata with framework configuration")
# Add component registration to metadata
# Add component registration to metadata (with file list for sync)
self.settings_manager.add_component_registration(
"core",
"framework_docs",
{
"version": __version__,
"category": "core",
"category": "documentation",
"files_count": len(self.component_files),
"files": list(self.component_files), # Track for sync/deletion
},
)
self.logger.info("Updated metadata with core component registration")
self.logger.info("Updated metadata with framework docs component registration")
# Migrate any existing SuperClaude data from settings.json
if self.settings_manager.migrate_superclaude_data():
@@ -86,23 +95,23 @@ class CoreComponent(Component):
if not self.file_manager.ensure_directory(dir_path):
self.logger.warning(f"Could not create directory: {dir_path}")
# Update CLAUDE.md with core framework imports
# Update CLAUDE.md with framework documentation imports
try:
manager = CLAUDEMdService(self.install_dir)
manager.add_imports(self.component_files, category="Core Framework")
self.logger.info("Updated CLAUDE.md with core framework imports")
manager.add_imports(self.component_files, category="Framework Documentation")
self.logger.info("Updated CLAUDE.md with framework documentation imports")
except Exception as e:
self.logger.warning(
f"Failed to update CLAUDE.md with core framework imports: {e}"
f"Failed to update CLAUDE.md with framework documentation imports: {e}"
)
# Don't fail the whole installation for this
return True
def uninstall(self) -> bool:
"""Uninstall core component"""
"""Uninstall framework docs component"""
try:
self.logger.info("Uninstalling SuperClaude core component...")
self.logger.info("Uninstalling SuperClaude framework docs component...")
# Remove framework files
removed_count = 0
@@ -114,10 +123,10 @@ class CoreComponent(Component):
else:
self.logger.warning(f"Could not remove {filename}")
# Update metadata to remove core component
# Update metadata to remove framework docs component
try:
if self.settings_manager.is_component_installed("core"):
self.settings_manager.remove_component_registration("core")
if self.settings_manager.is_component_installed("framework_docs"):
self.settings_manager.remove_component_registration("framework_docs")
metadata_mods = self.get_metadata_modifications()
metadata = self.settings_manager.load_metadata()
for key in metadata_mods.keys():
@@ -125,83 +134,86 @@ class CoreComponent(Component):
del metadata[key]
self.settings_manager.save_metadata(metadata)
self.logger.info("Removed core component from metadata")
self.logger.info("Removed framework docs component from metadata")
except Exception as e:
self.logger.warning(f"Could not update metadata: {e}")
self.logger.success(
f"Core component uninstalled ({removed_count} files removed)"
f"Framework docs component uninstalled ({removed_count} files removed)"
)
return True
except Exception as e:
self.logger.exception(f"Unexpected error during core uninstallation: {e}")
self.logger.exception(f"Unexpected error during framework docs uninstallation: {e}")
return False
def get_dependencies(self) -> List[str]:
"""Get component dependencies (core has none)"""
"""Get component dependencies (framework docs has none)"""
return []
def update(self, config: Dict[str, Any]) -> bool:
"""Update core component"""
"""
Sync framework docs component (overwrite + delete obsolete files).
No backup needed - SuperClaude source files are always authoritative.
"""
try:
self.logger.info("Updating SuperClaude core component...")
self.logger.info("Syncing SuperClaude framework docs component...")
# Check current version
current_version = self.settings_manager.get_component_version("core")
target_version = self.get_metadata()["version"]
if current_version == target_version:
self.logger.info(f"Core component already at version {target_version}")
return True
self.logger.info(
f"Updating core component from {current_version} to {target_version}"
# Get previously installed files from metadata
metadata = self.settings_manager.load_metadata()
previous_files = set(
metadata.get("components", {})
.get("framework_docs", {})
.get("files", [])
)
# Create backup of existing files
backup_files = []
for filename in self.component_files:
# Get current files from source
current_files = set(self.component_files)
# Files to delete (were installed before, but no longer in source)
files_to_delete = previous_files - current_files
# Delete obsolete files
deleted_count = 0
for filename in files_to_delete:
file_path = self.install_dir / filename
if file_path.exists():
backup_path = self.file_manager.backup_file(file_path)
if backup_path:
backup_files.append(backup_path)
self.logger.debug(f"Backed up {filename}")
try:
file_path.unlink()
deleted_count += 1
self.logger.info(f"Deleted obsolete file: {filename}")
except Exception as e:
self.logger.warning(f"Could not delete {filename}: {e}")
# Perform installation (overwrites existing files)
# Install/overwrite current files (no backup)
success = self.install(config)
if success:
# Remove backup files on successful update
for backup_path in backup_files:
try:
backup_path.unlink()
except Exception:
pass # Ignore cleanup errors
# Update metadata with current file list
self.settings_manager.add_component_registration(
"framework_docs",
{
"version": __version__,
"category": "documentation",
"files_count": len(current_files),
"files": list(current_files), # Track installed files
},
)
self.logger.success(
f"Core component updated to version {target_version}"
f"Framework docs synced: {len(current_files)} files, {deleted_count} obsolete files removed"
)
else:
# Restore from backup on failure
self.logger.warning("Update failed, restoring from backup...")
for backup_path in backup_files:
try:
original_path = backup_path.with_suffix("")
shutil.move(str(backup_path), str(original_path))
self.logger.debug(f"Restored {original_path.name}")
except Exception as e:
self.logger.error(f"Could not restore {backup_path}: {e}")
self.logger.error("Framework docs sync failed")
return success
except Exception as e:
self.logger.exception(f"Unexpected error during core update: {e}")
self.logger.exception(f"Unexpected error during framework docs sync: {e}")
return False
def validate_installation(self) -> Tuple[bool, List[str]]:
"""Validate core component installation"""
"""Validate framework docs component installation"""
errors = []
# Check if all framework files exist
@@ -213,11 +225,11 @@ class CoreComponent(Component):
errors.append(f"Framework file is not a regular file: {filename}")
# Check metadata registration
if not self.settings_manager.is_component_installed("core"):
errors.append("Core component not registered in metadata")
if not self.settings_manager.is_component_installed("framework_docs"):
errors.append("Framework docs component not registered in metadata")
else:
# Check version matches
installed_version = self.settings_manager.get_component_version("core")
installed_version = self.settings_manager.get_component_version("framework_docs")
expected_version = self.get_metadata()["version"]
if installed_version != expected_version:
errors.append(
@@ -240,9 +252,9 @@ class CoreComponent(Component):
return len(errors) == 0, errors
def _get_source_dir(self):
"""Get source directory for framework files"""
# Assume we're in superclaude/setup/components/core.py
# and framework files are in superclaude/superclaude/Core/
"""Get source directory for framework documentation files"""
# Assume we're in superclaude/setup/components/framework_docs.py
# and framework files are in superclaude/superclaude/core/
project_root = Path(__file__).parent.parent.parent
return project_root / "superclaude" / "core"

View File

@@ -13,7 +13,6 @@ from typing import Any, Dict, List, Optional, Tuple
from setup import __version__
from ..core.base import Component
from ..utils.ui import display_info, display_warning
class MCPComponent(Component):
@@ -25,7 +24,20 @@ class MCPComponent(Component):
self.installed_servers_in_session: List[str] = []
# Define MCP servers to install
self.mcp_servers = {
# Default: airis-mcp-gateway (unified gateway with all tools)
# Legacy mode (--legacy flag): individual official servers
self.mcp_servers_default = {
"airis-mcp-gateway": {
"name": "airis-mcp-gateway",
"description": "Unified MCP Gateway with all tools (sequential-thinking, context7, magic, playwright, serena, morphllm, tavily, chrome-devtools, git, puppeteer)",
"install_method": "github",
"install_command": "uvx --from git+https://github.com/oraios/airis-mcp-gateway airis-mcp-gateway --help",
"run_command": "uvx --from git+https://github.com/oraios/airis-mcp-gateway airis-mcp-gateway",
"required": True,
},
}
self.mcp_servers_legacy = {
"sequential-thinking": {
"name": "sequential-thinking",
"description": "Multi-step problem solving and systematic analysis",
@@ -52,54 +64,17 @@ class MCPComponent(Component):
"npm_package": "@playwright/mcp@latest",
"required": False,
},
"serena": {
"name": "serena",
"description": "Semantic code analysis and intelligent editing",
"install_method": "github",
"install_command": "uvx --from git+https://github.com/oraios/serena serena --help",
"run_command": "uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context ide-assistant --enable-web-dashboard false --enable-gui-log-window false",
"required": False,
},
"morphllm-fast-apply": {
"name": "morphllm-fast-apply",
"description": "Fast Apply capability for context-aware code modifications",
"npm_package": "@morph-llm/morph-fast-apply",
"required": False,
"api_key_env": "MORPH_API_KEY",
"api_key_description": "Morph API key for Fast Apply",
},
"tavily": {
"name": "tavily",
"description": "Web search and real-time information retrieval for deep research",
"install_method": "npm",
"install_command": "npx -y tavily-mcp@0.1.2",
"required": False,
"api_key_env": "TAVILY_API_KEY",
"api_key_description": "Tavily API key for web search (get from https://app.tavily.com)",
},
"chrome-devtools": {
"name": "chrome-devtools",
"description": "Chrome DevTools debugging and performance analysis",
"install_method": "npm",
"install_command": "npx -y chrome-devtools-mcp@latest",
"required": False,
},
"airis-mcp-gateway": {
"name": "airis-mcp-gateway",
"description": "Dynamic MCP Gateway for zero-token baseline and on-demand tool loading",
"install_method": "github",
"install_command": "uvx --from git+https://github.com/oraios/airis-mcp-gateway airis-mcp-gateway --help",
"run_command": "uvx --from git+https://github.com/oraios/airis-mcp-gateway airis-mcp-gateway",
"required": False,
},
}
# Default to unified gateway
self.mcp_servers = self.mcp_servers_default
def get_metadata(self) -> Dict[str, str]:
"""Get component metadata"""
return {
"name": "mcp",
"version": __version__,
"description": "MCP server integration (Context7, Sequential, Magic, Playwright)",
"description": "Unified MCP Gateway (airis-mcp-gateway) with all integrated tools",
"category": "integration",
}
@@ -137,33 +112,13 @@ class MCPComponent(Component):
def validate_prerequisites(
self, installSubPath: Optional[Path] = None
) -> Tuple[bool, List[str]]:
"""Check prerequisites"""
"""Check prerequisites (varies based on legacy mode)"""
errors = []
# Check if Node.js is available
try:
result = self._run_command_cross_platform(
["node", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
errors.append("Node.js not found - required for MCP servers")
else:
version = result.stdout.strip()
self.logger.debug(f"Found Node.js {version}")
# Check which server set we're using
is_legacy = self.mcp_servers == self.mcp_servers_legacy
# Check version (require 18+)
try:
version_num = int(version.lstrip("v").split(".")[0])
if version_num < 18:
errors.append(
f"Node.js version {version} found, but version 18+ required"
)
except:
self.logger.warning(f"Could not parse Node.js version: {version}")
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("Node.js not found - required for MCP servers")
# Check if Claude CLI is available
# Check if Claude CLI is available (always required)
try:
result = self._run_command_cross_platform(
["claude", "--version"], capture_output=True, text=True, timeout=10
@@ -178,35 +133,53 @@ class MCPComponent(Component):
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("Claude CLI not found - required for MCP server management")
# Check if npm is available
try:
result = self._run_command_cross_platform(
["npm", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
errors.append("npm not found - required for MCP server installation")
else:
version = result.stdout.strip()
self.logger.debug(f"Found npm {version}")
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("npm not found - required for MCP server installation")
# Check if uv is available (required for Serena)
try:
result = self._run_command_cross_platform(
["uv", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
self.logger.warning(
"uv not found - required for Serena MCP server installation"
if is_legacy:
# Legacy mode: requires Node.js and npm for official servers
try:
result = self._run_command_cross_platform(
["node", "--version"], capture_output=True, text=True, timeout=10
)
else:
version = result.stdout.strip()
self.logger.debug(f"Found uv {version}")
except (subprocess.TimeoutExpired, FileNotFoundError):
self.logger.warning(
"uv not found - required for Serena MCP server installation"
)
if result.returncode != 0:
errors.append("Node.js not found - required for legacy MCP servers")
else:
version = result.stdout.strip()
self.logger.debug(f"Found Node.js {version}")
# Check version (require 18+)
try:
version_num = int(version.lstrip("v").split(".")[0])
if version_num < 18:
errors.append(
f"Node.js version {version} found, but version 18+ required"
)
except:
self.logger.warning(f"Could not parse Node.js version: {version}")
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("Node.js not found - required for legacy MCP servers")
try:
result = self._run_command_cross_platform(
["npm", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
errors.append("npm not found - required for legacy MCP server installation")
else:
version = result.stdout.strip()
self.logger.debug(f"Found npm {version}")
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("npm not found - required for legacy MCP server installation")
else:
# Default mode: requires uv for airis-mcp-gateway
try:
result = self._run_command_cross_platform(
["uv", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
errors.append("uv not found - required for airis-mcp-gateway installation")
else:
version = result.stdout.strip()
self.logger.debug(f"Found uv {version}")
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("uv not found - required for airis-mcp-gateway installation")
return len(errors) == 0, errors
@@ -594,15 +567,9 @@ class MCPComponent(Component):
# Map common variations to our standard names
name_mappings = {
"context7": "context7",
"sequential-thinking": "sequential-thinking",
"sequential": "sequential-thinking",
"magic": "magic",
"playwright": "playwright",
"serena": "serena",
"morphllm": "morphllm-fast-apply",
"morphllm-fast-apply": "morphllm-fast-apply",
"morph": "morphllm-fast-apply",
"airis-mcp-gateway": "airis-mcp-gateway",
"airis": "airis-mcp-gateway",
"gateway": "airis-mcp-gateway",
}
return name_mappings.get(server_name)
@@ -672,15 +639,15 @@ class MCPComponent(Component):
)
if not config.get("dry_run", False):
display_info(f"MCP server '{server_name}' requires an API key")
display_info(f"Environment variable: {api_key_env}")
display_info(f"Description: {api_key_desc}")
self.logger.info(f"MCP server '{server_name}' requires an API key")
self.logger.info(f"Environment variable: {api_key_env}")
self.logger.info(f"Description: {api_key_desc}")
# Check if API key is already set
import os
if not os.getenv(api_key_env):
display_warning(
self.logger.warning(
f"API key {api_key_env} not found in environment"
)
self.logger.warning(
@@ -799,7 +766,15 @@ class MCPComponent(Component):
def _install(self, config: Dict[str, Any]) -> bool:
"""Install MCP component with auto-detection of existing servers"""
self.logger.info("Installing SuperClaude MCP servers...")
# Check for legacy mode flag
use_legacy = config.get("legacy_mode", False) or config.get("official_servers", False)
if use_legacy:
self.logger.info("Installing individual official MCP servers (legacy mode)...")
self.mcp_servers = self.mcp_servers_legacy
else:
self.logger.info("Installing unified MCP gateway (airis-mcp-gateway)...")
self.mcp_servers = self.mcp_servers_default
# Validate prerequisites
success, errors = self.validate_prerequisites()
@@ -966,7 +941,7 @@ class MCPComponent(Component):
def get_dependencies(self) -> List[str]:
"""Get dependencies"""
return ["core"]
return ["framework_docs"]
def update(self, config: Dict[str, Any]) -> bool:
"""Update MCP component"""
@@ -1096,9 +1071,21 @@ class MCPComponent(Component):
return {
"component": self.get_metadata()["name"],
"version": self.get_metadata()["version"],
"servers_count": len(self.mcp_servers),
"mcp_servers": list(self.mcp_servers.keys()),
"servers_count": 1, # Only airis-mcp-gateway
"mcp_servers": ["airis-mcp-gateway"],
"included_tools": [
"sequential-thinking",
"context7",
"magic",
"playwright",
"serena",
"morphllm",
"tavily",
"chrome-devtools",
"git",
"puppeteer",
],
"estimated_size": self.get_size_estimate(),
"dependencies": self.get_dependencies(),
"required_tools": ["node", "npm", "claude"],
"required_tools": ["uv", "claude"],
}

View File

@@ -1,374 +0,0 @@
"""
MCP Documentation component for SuperClaude MCP server documentation
"""
from typing import Dict, List, Tuple, Optional, Any
from pathlib import Path
from ..core.base import Component
from setup import __version__
from ..services.claude_md import CLAUDEMdService
class MCPDocsComponent(Component):
"""MCP documentation component - installs docs for selected MCP servers"""
def __init__(self, install_dir: Optional[Path] = None):
"""Initialize MCP docs component"""
# Initialize attributes before calling parent constructor
# because parent calls _discover_component_files() which needs these
self.selected_servers: List[str] = []
# Map server names to documentation files
self.server_docs_map = {
"context7": "MCP_Context7.md",
"sequential": "MCP_Sequential.md",
"sequential-thinking": "MCP_Sequential.md", # Handle both naming conventions
"magic": "MCP_Magic.md",
"playwright": "MCP_Playwright.md",
"serena": "MCP_Serena.md",
"morphllm": "MCP_Morphllm.md",
"morphllm-fast-apply": "MCP_Morphllm.md", # Handle both naming conventions
"tavily": "MCP_Tavily.md",
}
super().__init__(install_dir, Path(""))
def get_metadata(self) -> Dict[str, str]:
"""Get component metadata"""
return {
"name": "mcp_docs",
"version": __version__,
"description": "MCP server documentation and usage guides",
"category": "documentation",
}
def is_reinstallable(self) -> bool:
"""
Allow mcp_docs to be reinstalled to handle different server selections.
This enables users to add or change MCP server documentation.
"""
return True
def set_selected_servers(self, selected_servers: List[str]) -> None:
"""Set which MCP servers were selected for documentation installation"""
self.selected_servers = selected_servers
self.logger.debug(f"MCP docs will be installed for: {selected_servers}")
def get_files_to_install(self) -> List[Tuple[Path, Path]]:
"""
Return list of files to install based on selected MCP servers
Returns:
List of tuples (source_path, target_path)
"""
source_dir = self._get_source_dir()
files = []
if source_dir and self.selected_servers:
for server_name in self.selected_servers:
if server_name in self.server_docs_map:
doc_file = self.server_docs_map[server_name]
source = source_dir / doc_file
target = self.install_dir / doc_file
if source.exists():
files.append((source, target))
self.logger.debug(
f"Will install documentation for {server_name}: {doc_file}"
)
else:
self.logger.warning(
f"Documentation file not found for {server_name}: {doc_file}"
)
return files
def _discover_component_files(self) -> List[str]:
"""
Override parent method to dynamically discover files based on selected servers
"""
files = []
# Check if selected_servers is not empty
if self.selected_servers:
for server_name in self.selected_servers:
if server_name in self.server_docs_map:
files.append(self.server_docs_map[server_name])
return files
def _detect_existing_mcp_servers_from_config(self) -> List[str]:
"""Detect existing MCP servers from Claude Desktop config"""
detected_servers = []
try:
# Try to find Claude Desktop config file
config_paths = [
self.install_dir / "claude_desktop_config.json",
Path.home() / ".claude" / "claude_desktop_config.json",
Path.home() / ".claude.json", # Claude CLI config
Path.home()
/ "AppData"
/ "Roaming"
/ "Claude"
/ "claude_desktop_config.json", # Windows
Path.home()
/ "Library"
/ "Application Support"
/ "Claude"
/ "claude_desktop_config.json", # macOS
]
config_file = None
for path in config_paths:
if path.exists():
config_file = path
break
if not config_file:
self.logger.debug("No Claude Desktop config file found")
return detected_servers
import json
with open(config_file, "r") as f:
config = json.load(f)
# Extract MCP server names from mcpServers section
mcp_servers = config.get("mcpServers", {})
for server_name in mcp_servers.keys():
# Map common name variations to our doc file names
normalized_name = self._normalize_server_name(server_name)
if normalized_name and normalized_name in self.server_docs_map:
detected_servers.append(normalized_name)
if detected_servers:
self.logger.info(
f"Detected existing MCP servers from config: {detected_servers}"
)
except Exception as e:
self.logger.warning(f"Could not read Claude Desktop config: {e}")
return detected_servers
def _normalize_server_name(self, server_name: str) -> Optional[str]:
"""Normalize server name to match our documentation mapping"""
if not server_name:
return None
server_name = server_name.lower().strip()
# Map common variations to our server_docs_map keys
name_mappings = {
"context7": "context7",
"sequential-thinking": "sequential-thinking",
"sequential": "sequential-thinking",
"magic": "magic",
"playwright": "playwright",
"serena": "serena",
"morphllm": "morphllm",
"morphllm-fast-apply": "morphllm",
"morph": "morphllm",
}
return name_mappings.get(server_name)
def _install(self, config: Dict[str, Any]) -> bool:
"""Install MCP documentation component with auto-detection"""
self.logger.info("Installing MCP server documentation...")
# Auto-detect existing servers
self.logger.info("Auto-detecting existing MCP servers for documentation...")
detected_servers = self._detect_existing_mcp_servers_from_config()
# Get selected servers from config
selected_servers = config.get("selected_mcp_servers", [])
# Get previously documented servers from metadata
previous_servers = self.settings_manager.get_metadata_setting(
"components.mcp_docs.servers_documented", []
)
# Merge all server lists
all_servers = list(set(detected_servers + selected_servers + previous_servers))
# Filter to only servers we have documentation for
valid_servers = [s for s in all_servers if s in self.server_docs_map]
if not valid_servers:
self.logger.info(
"No MCP servers detected or selected for documentation installation"
)
# Still proceed to update metadata
self.set_selected_servers([])
self.component_files = []
return self._post_install()
self.logger.info(
f"Installing documentation for MCP servers: {', '.join(valid_servers)}"
)
if detected_servers:
self.logger.info(f" - Detected from config: {detected_servers}")
if selected_servers:
self.logger.info(f" - Newly selected: {selected_servers}")
if previous_servers:
self.logger.info(f" - Previously documented: {previous_servers}")
# Set the servers for which we'll install documentation
self.set_selected_servers(valid_servers)
self.component_files = self._discover_component_files()
# Validate installation
success, errors = self.validate_prerequisites()
if not success:
for error in errors:
self.logger.error(error)
return False
# Get files to install
files_to_install = self.get_files_to_install()
if not files_to_install:
self.logger.warning("No MCP documentation files found to install")
return False
# Copy documentation files
success_count = 0
successfully_copied_files = []
for source, target in files_to_install:
self.logger.debug(f"Copying {source.name} to {target}")
if self.file_manager.copy_file(source, target):
success_count += 1
successfully_copied_files.append(source.name)
self.logger.debug(f"Successfully copied {source.name}")
else:
self.logger.error(f"Failed to copy {source.name}")
if success_count != len(files_to_install):
self.logger.error(
f"Only {success_count}/{len(files_to_install)} documentation files copied successfully"
)
return False
# Update component_files to only include successfully copied files
self.component_files = successfully_copied_files
self.logger.success(
f"MCP documentation installed successfully ({success_count} files for {len(valid_servers)} servers)"
)
return self._post_install()
def _post_install(self) -> bool:
"""Post-installation tasks"""
try:
# Update metadata
metadata_mods = {
"components": {
"mcp_docs": {
"version": __version__,
"installed": True,
"files_count": len(self.component_files),
"servers_documented": self.selected_servers,
}
}
}
self.settings_manager.update_metadata(metadata_mods)
self.logger.info("Updated metadata with MCP docs component registration")
# Update CLAUDE.md with MCP documentation imports
try:
manager = CLAUDEMdService(self.install_dir)
manager.add_imports(self.component_files, category="MCP Documentation")
self.logger.info("Updated CLAUDE.md with MCP documentation imports")
except Exception as e:
self.logger.warning(
f"Failed to update CLAUDE.md with MCP documentation imports: {e}"
)
# Don't fail the whole installation for this
return True
except Exception as e:
self.logger.error(f"Failed to update metadata: {e}")
return False
def uninstall(self) -> bool:
"""Uninstall MCP documentation component"""
try:
self.logger.info("Uninstalling MCP documentation component...")
# Remove all MCP documentation files
removed_count = 0
source_dir = self._get_source_dir()
if source_dir and source_dir.exists():
# Remove all possible MCP doc files
for doc_file in self.server_docs_map.values():
file_path = self.install_component_subdir / doc_file
if self.file_manager.remove_file(file_path):
removed_count += 1
self.logger.debug(f"Removed {doc_file}")
# Remove mcp directory if empty
try:
if self.install_component_subdir.exists():
remaining_files = list(self.install_component_subdir.iterdir())
if not remaining_files:
self.install_component_subdir.rmdir()
self.logger.debug("Removed empty mcp directory")
except Exception as e:
self.logger.warning(f"Could not remove mcp directory: {e}")
# Update settings.json
try:
if self.settings_manager.is_component_installed("mcp_docs"):
self.settings_manager.remove_component_registration("mcp_docs")
self.logger.info("Removed MCP docs component from settings.json")
except Exception as e:
self.logger.warning(f"Could not update settings.json: {e}")
self.logger.success(
f"MCP documentation uninstalled ({removed_count} files removed)"
)
return True
except Exception as e:
self.logger.exception(
f"Unexpected error during MCP docs uninstallation: {e}"
)
return False
def get_dependencies(self) -> List[str]:
"""Get dependencies"""
return ["core"]
def _get_source_dir(self) -> Optional[Path]:
"""Get source directory for MCP documentation files"""
# Assume we're in superclaude/setup/components/mcp_docs.py
# and MCP docs are in superclaude/superclaude/MCP/
project_root = Path(__file__).parent.parent.parent
mcp_dir = project_root / "superclaude" / "mcp"
# Return None if directory doesn't exist to prevent warning
if not mcp_dir.exists():
return None
return mcp_dir
def get_size_estimate(self) -> int:
"""Get estimated installation size"""
source_dir = self._get_source_dir()
total_size = 0
if source_dir and source_dir.exists() and self.selected_servers:
for server_name in self.selected_servers:
if server_name in self.server_docs_map:
doc_file = self.server_docs_map[server_name]
file_path = source_dir / doc_file
if file_path.exists():
total_size += file_path.stat().st_size
# Minimum size estimate
total_size = max(total_size, 10240) # At least 10KB
return total_size

View File

@@ -26,6 +26,13 @@ class ModesComponent(Component):
"category": "modes",
}
def is_reinstallable(self) -> bool:
"""
Modes should always be synced to latest version.
SuperClaude mode files always overwrite existing files.
"""
return True
def _install(self, config: Dict[str, Any]) -> bool:
"""Install modes component"""
self.logger.info("Installing SuperClaude behavioral modes...")
@@ -77,6 +84,7 @@ class ModesComponent(Component):
"version": __version__,
"installed": True,
"files_count": len(self.component_files),
"files": list(self.component_files), # Track for sync/deletion
}
}
}
@@ -140,7 +148,68 @@ class ModesComponent(Component):
def get_dependencies(self) -> List[str]:
"""Get dependencies"""
return ["core"]
return ["framework_docs"]
def update(self, config: Dict[str, Any]) -> bool:
"""
Sync modes component (overwrite + delete obsolete files).
No backup needed - SuperClaude source files are always authoritative.
"""
try:
self.logger.info("Syncing SuperClaude modes component...")
# Get previously installed files from metadata
metadata = self.settings_manager.load_metadata()
previous_files = set(
metadata.get("components", {}).get("modes", {}).get("files", [])
)
# Get current files from source
current_files = set(self.component_files)
# Files to delete (were installed before, but no longer in source)
files_to_delete = previous_files - current_files
# Delete obsolete files
deleted_count = 0
for filename in files_to_delete:
file_path = self.install_dir / filename
if file_path.exists():
try:
file_path.unlink()
deleted_count += 1
self.logger.info(f"Deleted obsolete mode: {filename}")
except Exception as e:
self.logger.warning(f"Could not delete {filename}: {e}")
# Install/overwrite current files (no backup)
success = self.install(config)
if success:
# Update metadata with current file list
metadata_mods = {
"components": {
"modes": {
"version": __version__,
"installed": True,
"files_count": len(current_files),
"files": list(current_files), # Track installed files
}
}
}
self.settings_manager.update_metadata(metadata_mods)
self.logger.success(
f"Modes synced: {len(current_files)} files, {deleted_count} obsolete files removed"
)
else:
self.logger.error("Modes sync failed")
return success
except Exception as e:
self.logger.exception(f"Unexpected error during modes sync: {e}")
return False
def _get_source_dir(self) -> Optional[Path]:
"""Get source directory for mode files"""

View File

@@ -37,7 +37,6 @@ class Installer:
self.failed_components: Set[str] = set()
self.skipped_components: Set[str] = set()
self.backup_path: Optional[Path] = None
self.logger = get_logger()
def register_component(self, component: Component) -> None:
@@ -132,59 +131,6 @@ class Installer:
return len(errors) == 0, errors
def create_backup(self) -> Optional[Path]:
"""
Create backup of existing installation
Returns:
Path to backup archive or None if no existing installation
"""
if not self.install_dir.exists():
return None
if self.dry_run:
return self.install_dir / "backup_dryrun.tar.gz"
# Create backup directory
backup_dir = self.install_dir / "backups"
backup_dir.mkdir(exist_ok=True)
# Create timestamped backup
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"superclaude_backup_{timestamp}"
backup_path = backup_dir / f"{backup_name}.tar.gz"
# Create temporary directory for backup
with tempfile.TemporaryDirectory() as temp_dir:
temp_backup = Path(temp_dir) / backup_name
# Ensure temp backup directory exists
temp_backup.mkdir(parents=True, exist_ok=True)
# Copy all files except backups and local directories
for item in self.install_dir.iterdir():
if item.name not in ["backups", "local"]:
try:
if item.is_file():
shutil.copy2(item, temp_backup / item.name)
elif item.is_dir():
shutil.copytree(item, temp_backup / item.name)
except Exception as e:
# Log warning but continue backup process
self.logger.warning(f"Could not backup {item.name}: {e}")
# Always create an archive, even if empty, to ensure it's a valid tarball
base_path = backup_dir / backup_name
shutil.make_archive(str(base_path), "gztar", temp_backup)
if not any(temp_backup.iterdir()):
self.logger.warning(
f"No files to backup, created empty backup archive: {backup_path.name}"
)
self.backup_path = backup_path
return backup_path
def install_component(self, component_name: str, config: Dict[str, Any]) -> bool:
"""
Install a single component
@@ -201,12 +147,25 @@ class Installer:
component = self.components[component_name]
# Skip if already installed and not in update mode, unless component is reinstallable
if (
# Framework components are ALWAYS updated to latest version
# These are SuperClaude implementation files, not user configurations
framework_components = {'framework_docs', 'agents', 'commands', 'modes', 'core', 'mcp'}
if component_name in framework_components:
# Always update framework components to latest version
if component_name in self.installed_components:
self.logger.info(f"Updating framework component to latest version: {component_name}")
else:
self.logger.info(f"Installing framework component: {component_name}")
# Force update for framework components
config = {**config, 'force_update': True}
elif (
not component.is_reinstallable()
and component_name in self.installed_components
and not config.get("update_mode")
and not config.get("force")
):
# Only skip non-framework components that are already installed
self.skipped_components.add(component_name)
self.logger.info(f"Skipping already installed component: {component_name}")
return True
@@ -220,13 +179,17 @@ class Installer:
self.failed_components.add(component_name)
return False
# Perform installation
# Perform installation or update
try:
if self.dry_run:
self.logger.info(f"[DRY RUN] Would install {component_name}")
success = True
else:
success = component.install(config)
# If component is already installed and this is a framework component, call update() instead of install()
if component_name in self.installed_components and component_name in framework_components:
success = component.update(config)
else:
success = component.install(config)
if success:
self.installed_components.add(component_name)
@@ -271,15 +234,6 @@ class Installer:
self.logger.error(f" - {error}")
return False
# Create backup if updating
if self.install_dir.exists() and not self.dry_run:
self.logger.info("Creating backup of existing installation...")
try:
self.create_backup()
except Exception as e:
self.logger.error(f"Failed to create backup: {e}")
return False
# Install each component
all_success = True
for name in ordered_names:
@@ -339,7 +293,6 @@ class Installer:
"installed": list(self.installed_components),
"failed": list(self.failed_components),
"skipped": list(self.skipped_components),
"backup_path": str(self.backup_path) if self.backup_path else None,
"install_dir": str(self.install_dir),
"dry_run": self.dry_run,
}
@@ -348,5 +301,4 @@ class Installer:
return {
"updated": list(self.updated_components),
"failed": list(self.failed_components),
"backup_path": str(self.backup_path) if self.backup_path else None,
}

View File

@@ -36,15 +36,6 @@
"enabled": true,
"required_tools": []
},
"mcp_docs": {
"name": "mcp_docs",
"version": "4.1.5",
"description": "MCP server documentation and usage guides",
"category": "documentation",
"dependencies": ["core"],
"enabled": true,
"required_tools": []
},
"agents": {
"name": "agents",
"version": "4.1.5",

View File

@@ -16,10 +16,11 @@ class CLAUDEMdService:
Initialize CLAUDEMdService
Args:
install_dir: Installation directory (typically ~/.claude)
install_dir: Installation directory (typically ~/.claude/superclaude)
"""
self.install_dir = install_dir
self.claude_md_path = install_dir / "CLAUDE.md"
# CLAUDE.md is always in parent directory (~/.claude/)
self.claude_md_path = install_dir.parent / "CLAUDE.md"
self.logger = get_logger()
def read_existing_imports(self) -> Set[str]:
@@ -39,7 +40,8 @@ class CLAUDEMdService:
content = f.read()
# Find all @import statements using regex
import_pattern = r"^@([^\s\n]+\.md)\s*$"
# Supports both @superclaude/file.md and @file.md (legacy)
import_pattern = r"^@(?:superclaude/)?([^\s\n]+\.md)\s*$"
matches = re.findall(import_pattern, content, re.MULTILINE)
existing_imports.update(matches)
@@ -116,7 +118,8 @@ class CLAUDEMdService:
if files:
sections.append(f"# {category}")
for file in sorted(files):
sections.append(f"@{file}")
# Add superclaude/ prefix for all imports
sections.append(f"@superclaude/{file}")
sections.append("")
return "\n".join(sections)
@@ -133,8 +136,10 @@ class CLAUDEMdService:
True if successful, False otherwise
"""
try:
# Ensure CLAUDE.md exists
self.ensure_claude_md_exists()
# Check if CLAUDE.md exists (DO NOT create it)
if not self.ensure_claude_md_exists():
self.logger.info("Skipping CLAUDE.md update (file does not exist)")
return False
# Read existing content and imports
existing_content = self.read_existing_content()
@@ -235,39 +240,36 @@ class CLAUDEMdService:
# Import line (starts with @)
elif line.startswith("@") and current_category:
import_file = line[1:].strip() # Remove "@"
# Remove superclaude/ prefix if present (normalize to filename only)
if import_file.startswith("superclaude/"):
import_file = import_file[len("superclaude/"):]
if import_file not in imports_by_category[current_category]:
imports_by_category[current_category].append(import_file)
return imports_by_category
def ensure_claude_md_exists(self) -> None:
def ensure_claude_md_exists(self) -> bool:
"""
Create CLAUDE.md with default content if it doesn't exist
Check if CLAUDE.md exists (DO NOT create it - Claude Code pure file)
Returns:
True if CLAUDE.md exists, False otherwise
"""
if self.claude_md_path.exists():
return
return True
try:
# Create directory if it doesn't exist
self.claude_md_path.parent.mkdir(parents=True, exist_ok=True)
# Default CLAUDE.md content
default_content = """# SuperClaude Entry Point
This file serves as the entry point for the SuperClaude framework.
You can add your own custom instructions and configurations here.
The SuperClaude framework components will be automatically imported below.
"""
with open(self.claude_md_path, "w", encoding="utf-8") as f:
f.write(default_content)
self.logger.info("Created CLAUDE.md with default content")
except Exception as e:
self.logger.error(f"Failed to create CLAUDE.md: {e}")
raise
# CLAUDE.md is a Claude Code pure file - NEVER create or modify it
self.logger.warning(
f"⚠️ CLAUDE.md not found at {self.claude_md_path}\n"
f" SuperClaude will NOT create this file automatically.\n"
f" Please manually add the following to your CLAUDE.md:\n\n"
f" # SuperClaude Framework Components\n"
f" @superclaude/FLAGS.md\n"
f" @superclaude/PRINCIPLES.md\n"
f" @superclaude/RULES.md\n"
f" (and other SuperClaude components)\n"
)
return False
def remove_imports(self, files: List[str]) -> bool:
"""

View File

@@ -1,7 +1,10 @@
"""Utility modules for SuperClaude installation system"""
"""Utility modules for SuperClaude installation system
Note: UI utilities (ProgressBar, Menu, confirm, Colors) have been removed.
The new CLI uses typer + rich natively via superclaude/cli/
"""
from .ui import ProgressBar, Menu, confirm, Colors
from .logger import Logger
from .security import SecurityValidator
__all__ = ["ProgressBar", "Menu", "confirm", "Colors", "Logger", "SecurityValidator"]
__all__ = ["Logger", "SecurityValidator"]

View File

@@ -9,10 +9,13 @@ from pathlib import Path
from typing import Optional, Dict, Any
from enum import Enum
from .ui import Colors
from rich.console import Console
from .symbols import symbols
from .paths import get_home_directory
# Rich console for colored output
console = Console()
class LogLevel(Enum):
"""Log levels"""
@@ -69,37 +72,23 @@ class Logger:
}
def _setup_console_handler(self) -> None:
"""Setup colorized console handler"""
handler = logging.StreamHandler(sys.stdout)
"""Setup colorized console handler using rich"""
from rich.logging import RichHandler
handler = RichHandler(
console=console,
show_time=False,
show_path=False,
markup=True,
rich_tracebacks=True,
tracebacks_show_locals=False,
)
handler.setLevel(self.console_level.value)
# Custom formatter with colors
class ColorFormatter(logging.Formatter):
def format(self, record):
# Color mapping
colors = {
"DEBUG": Colors.WHITE,
"INFO": Colors.BLUE,
"WARNING": Colors.YELLOW,
"ERROR": Colors.RED,
"CRITICAL": Colors.RED + Colors.BRIGHT,
}
# Simple formatter (rich handles coloring)
formatter = logging.Formatter("%(message)s")
handler.setFormatter(formatter)
# Prefix mapping
prefixes = {
"DEBUG": "[DEBUG]",
"INFO": "[INFO]",
"WARNING": "[!]",
"ERROR": f"[{symbols.crossmark}]",
"CRITICAL": "[CRITICAL]",
}
color = colors.get(record.levelname, Colors.WHITE)
prefix = prefixes.get(record.levelname, "[LOG]")
return f"{color}{prefix} {record.getMessage()}{Colors.RESET}"
handler.setFormatter(ColorFormatter())
self.logger.addHandler(handler)
def _setup_file_handler(self) -> None:
@@ -130,7 +119,7 @@ class Logger:
except Exception as e:
# If file logging fails, continue with console only
print(f"{Colors.YELLOW}[!] Could not setup file logging: {e}{Colors.RESET}")
console.print(f"[yellow][!] Could not setup file logging: {e}[/yellow]")
self.log_file = None
def _cleanup_old_logs(self, keep_count: int = 10) -> None:
@@ -179,23 +168,9 @@ class Logger:
def success(self, message: str, **kwargs) -> None:
"""Log success message (info level with special formatting)"""
# Use a custom success formatter for console
if self.logger.handlers:
console_handler = self.logger.handlers[0]
if hasattr(console_handler, "formatter"):
original_format = console_handler.formatter.format
def success_format(record):
return f"{Colors.GREEN}[{symbols.checkmark}] {record.getMessage()}{Colors.RESET}"
console_handler.formatter.format = success_format
self.logger.info(message, **kwargs)
console_handler.formatter.format = original_format
else:
self.logger.info(f"SUCCESS: {message}", **kwargs)
else:
self.logger.info(f"SUCCESS: {message}", **kwargs)
# Use rich markup for success messages
success_msg = f"[green]{symbols.checkmark} {message}[/green]"
self.logger.info(success_msg, **kwargs)
self.log_counts["info"] += 1
def step(self, step: int, total: int, message: str, **kwargs) -> None:

View File

@@ -1,552 +1,203 @@
"""
User interface utilities for SuperClaude installation system
Cross-platform console UI with colors and progress indication
Minimal backward-compatible UI utilities
Stub implementation for legacy installer code
"""
import sys
import time
import shutil
import getpass
from typing import List, Optional, Any, Dict, Union
from enum import Enum
from .symbols import symbols, safe_print, format_with_symbols
# Try to import colorama for cross-platform color support
try:
import colorama
from colorama import Fore, Back, Style
colorama.init(autoreset=True)
COLORAMA_AVAILABLE = True
except ImportError:
COLORAMA_AVAILABLE = False
# Fallback color codes for Unix-like systems
class MockFore:
RED = "\033[91m" if sys.platform != "win32" else ""
GREEN = "\033[92m" if sys.platform != "win32" else ""
YELLOW = "\033[93m" if sys.platform != "win32" else ""
BLUE = "\033[94m" if sys.platform != "win32" else ""
MAGENTA = "\033[95m" if sys.platform != "win32" else ""
CYAN = "\033[96m" if sys.platform != "win32" else ""
WHITE = "\033[97m" if sys.platform != "win32" else ""
class MockStyle:
RESET_ALL = "\033[0m" if sys.platform != "win32" else ""
BRIGHT = "\033[1m" if sys.platform != "win32" else ""
Fore = MockFore()
Style = MockStyle()
class Colors:
"""Color constants for console output"""
"""ANSI color codes for terminal output"""
RED = Fore.RED
GREEN = Fore.GREEN
YELLOW = Fore.YELLOW
BLUE = Fore.BLUE
MAGENTA = Fore.MAGENTA
CYAN = Fore.CYAN
WHITE = Fore.WHITE
RESET = Style.RESET_ALL
BRIGHT = Style.BRIGHT
RESET = "\033[0m"
BRIGHT = "\033[1m"
DIM = "\033[2m"
BLACK = "\033[30m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
BG_BLACK = "\033[40m"
BG_RED = "\033[41m"
BG_GREEN = "\033[42m"
BG_YELLOW = "\033[43m"
BG_BLUE = "\033[44m"
BG_MAGENTA = "\033[45m"
BG_CYAN = "\033[46m"
BG_WHITE = "\033[47m"
class ProgressBar:
"""Cross-platform progress bar with customizable display"""
def __init__(self, total: int, width: int = 50, prefix: str = "", suffix: str = ""):
"""
Initialize progress bar
Args:
total: Total number of items to process
width: Width of progress bar in characters
prefix: Text to display before progress bar
suffix: Text to display after progress bar
"""
self.total = total
self.width = width
self.prefix = prefix
self.suffix = suffix
self.current = 0
self.start_time = time.time()
# Get terminal width for responsive display
try:
self.terminal_width = shutil.get_terminal_size().columns
except OSError:
self.terminal_width = 80
def update(self, current: int, message: str = "") -> None:
"""
Update progress bar
Args:
current: Current progress value
message: Optional message to display
"""
self.current = current
percent = min(100, (current / self.total) * 100) if self.total > 0 else 100
# Calculate filled and empty portions
filled_width = (
int(self.width * current / self.total) if self.total > 0 else self.width
)
filled = symbols.block_filled * filled_width
empty = symbols.block_empty * (self.width - filled_width)
# Calculate elapsed time and ETA
elapsed = time.time() - self.start_time
if current > 0:
eta = (elapsed / current) * (self.total - current)
eta_str = f" ETA: {self._format_time(eta)}"
else:
eta_str = ""
# Format progress line
if message:
status = f" {message}"
else:
status = ""
progress_line = (
f"\r{self.prefix}[{Colors.GREEN}{filled}{Colors.WHITE}{empty}{Colors.RESET}] "
f"{percent:5.1f}%{status}{eta_str}"
)
# Truncate if too long for terminal
max_length = self.terminal_width - 5
if len(progress_line) > max_length:
# Remove color codes for length calculation
plain_line = (
progress_line.replace(Colors.GREEN, "")
.replace(Colors.WHITE, "")
.replace(Colors.RESET, "")
)
if len(plain_line) > max_length:
progress_line = progress_line[:max_length] + "..."
safe_print(progress_line, end="", flush=True)
def increment(self, message: str = "") -> None:
"""
Increment progress by 1
Args:
message: Optional message to display
"""
self.update(self.current + 1, message)
def finish(self, message: str = "Complete") -> None:
"""
Complete progress bar
Args:
message: Completion message
"""
self.update(self.total, message)
print() # New line after completion
def _format_time(self, seconds: float) -> str:
"""Format time duration as human-readable string"""
if seconds < 60:
return f"{seconds:.0f}s"
elif seconds < 3600:
return f"{seconds/60:.0f}m {seconds%60:.0f}s"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{hours:.0f}h {minutes:.0f}m"
def display_header(title: str, subtitle: str = "") -> None:
"""Display a formatted header"""
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{title}{Colors.RESET}")
if subtitle:
print(f"{Colors.DIM}{subtitle}{Colors.RESET}")
print()
class Menu:
"""Interactive menu system with keyboard navigation"""
def __init__(self, title: str, options: List[str], multi_select: bool = False):
"""
Initialize menu
Args:
title: Menu title
options: List of menu options
multi_select: Allow multiple selections
"""
self.title = title
self.options = options
self.multi_select = multi_select
self.selected = set() if multi_select else None
def display(self) -> Union[int, List[int]]:
"""
Display menu and get user selection
Returns:
Selected option index (single) or list of indices (multi-select)
"""
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{self.title}{Colors.RESET}")
print("=" * len(self.title))
for i, option in enumerate(self.options, 1):
if self.multi_select:
marker = "[x]" if i - 1 in (self.selected or set()) else "[ ]"
print(f"{Colors.YELLOW}{i:2d}.{Colors.RESET} {marker} {option}")
else:
print(f"{Colors.YELLOW}{i:2d}.{Colors.RESET} {option}")
if self.multi_select:
print(
f"\n{Colors.BLUE}Enter numbers separated by commas (e.g., 1,3,5) or 'all' for all options:{Colors.RESET}"
)
else:
print(
f"\n{Colors.BLUE}Enter your choice (1-{len(self.options)}):{Colors.RESET}"
)
while True:
try:
user_input = input("> ").strip().lower()
if self.multi_select:
if user_input == "all":
return list(range(len(self.options)))
elif user_input == "":
return []
else:
# Parse comma-separated numbers
selections = []
for part in user_input.split(","):
part = part.strip()
if part.isdigit():
idx = int(part) - 1
if 0 <= idx < len(self.options):
selections.append(idx)
else:
raise ValueError(f"Invalid option: {part}")
else:
raise ValueError(f"Invalid input: {part}")
return list(set(selections)) # Remove duplicates
else:
if user_input.isdigit():
choice = int(user_input) - 1
if 0 <= choice < len(self.options):
return choice
else:
print(
f"{Colors.RED}Invalid choice. Please enter a number between 1 and {len(self.options)}.{Colors.RESET}"
)
else:
print(f"{Colors.RED}Please enter a valid number.{Colors.RESET}")
except (ValueError, KeyboardInterrupt) as e:
if isinstance(e, KeyboardInterrupt):
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
return [] if self.multi_select else -1
else:
print(f"{Colors.RED}Invalid input: {e}{Colors.RESET}")
def display_success(message: str) -> None:
"""Display a success message"""
print(f"{Colors.GREEN}{message}{Colors.RESET}")
def confirm(message: str, default: bool = True) -> bool:
def display_error(message: str) -> None:
"""Display an error message"""
print(f"{Colors.RED}{message}{Colors.RESET}")
def display_warning(message: str) -> None:
"""Display a warning message"""
print(f"{Colors.YELLOW}{message}{Colors.RESET}")
def display_info(message: str) -> None:
"""Display an info message"""
print(f"{Colors.CYAN} {message}{Colors.RESET}")
def confirm(prompt: str, default: bool = True) -> bool:
"""
Ask for user confirmation
Simple confirmation prompt
Args:
message: Confirmation message
prompt: The prompt message
default: Default response if user just presses Enter
Returns:
True if confirmed, False otherwise
"""
suffix = "[Y/n]" if default else "[y/N]"
print(f"{Colors.BLUE}{message} {suffix}{Colors.RESET}")
default_str = "Y/n" if default else "y/N"
response = input(f"{prompt} [{default_str}]: ").strip().lower()
while True:
try:
response = input("> ").strip().lower()
if not response:
return default
if response == "":
return default
elif response in ["y", "yes", "true", "1"]:
return True
elif response in ["n", "no", "false", "0"]:
return False
else:
print(
f"{Colors.RED}Please enter 'y' or 'n' (or press Enter for default).{Colors.RESET}"
)
except KeyboardInterrupt:
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
return False
return response in ("y", "yes")
def display_header(title: str, subtitle: str = "") -> None:
"""
Display formatted header
class Menu:
"""Minimal menu implementation"""
Args:
title: Main title
subtitle: Optional subtitle
"""
from superclaude import __author__, __email__
def __init__(self, title: str, options: list, multi_select: bool = False):
self.title = title
self.options = options
self.multi_select = multi_select
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}")
print(f"{Colors.CYAN}{Colors.BRIGHT}{title:^60}{Colors.RESET}")
if subtitle:
print(f"{Colors.WHITE}{subtitle:^60}{Colors.RESET}")
def display(self):
"""Display menu and get selection"""
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{self.title}{Colors.RESET}\n")
# Display authors
authors = [a.strip() for a in __author__.split(",")]
emails = [e.strip() for e in __email__.split(",")]
for i, option in enumerate(self.options, 1):
print(f"{i}. {option}")
author_lines = []
for i in range(len(authors)):
name = authors[i]
email = emails[i] if i < len(emails) else ""
author_lines.append(f"{name} <{email}>")
if self.multi_select:
print(f"\n{Colors.DIM}Enter comma-separated numbers (e.g., 1,3,5) or 'all' for all options{Colors.RESET}")
while True:
try:
choice = input(f"Select [1-{len(self.options)}]: ").strip().lower()
authors_str = " | ".join(author_lines)
print(f"{Colors.BLUE}{authors_str:^60}{Colors.RESET}")
if choice == "all":
return list(range(len(self.options)))
print(f"{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}\n")
if not choice:
return []
selections = [int(x.strip()) - 1 for x in choice.split(",")]
if all(0 <= s < len(self.options) for s in selections):
return selections
print(f"{Colors.RED}Invalid selection{Colors.RESET}")
except (ValueError, KeyboardInterrupt):
print(f"\n{Colors.RED}Invalid input{Colors.RESET}")
else:
while True:
try:
choice = input(f"\nSelect [1-{len(self.options)}]: ").strip()
choice_num = int(choice)
if 1 <= choice_num <= len(self.options):
return choice_num - 1
print(f"{Colors.RED}Invalid selection{Colors.RESET}")
except (ValueError, KeyboardInterrupt):
print(f"\n{Colors.RED}Invalid input{Colors.RESET}")
def display_authors() -> None:
"""Display author information"""
from superclaude import __author__, __email__, __github__
class ProgressBar:
"""Minimal progress bar implementation"""
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}")
print(f"{Colors.CYAN}{Colors.BRIGHT}{'superclaude Authors':^60}{Colors.RESET}")
print(f"{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}\n")
authors = [a.strip() for a in __author__.split(",")]
emails = [e.strip() for e in __email__.split(",")]
github_users = [g.strip() for g in __github__.split(",")]
for i in range(len(authors)):
name = authors[i]
email = emails[i] if i < len(emails) else "N/A"
github = github_users[i] if i < len(github_users) else "N/A"
print(f" {Colors.BRIGHT}{name}{Colors.RESET}")
print(f" Email: {Colors.YELLOW}{email}{Colors.RESET}")
print(f" GitHub: {Colors.YELLOW}https://github.com/{github}{Colors.RESET}")
print()
print(f"{Colors.CYAN}{'='*60}{Colors.RESET}\n")
def display_info(message: str) -> None:
"""Display info message"""
print(f"{Colors.BLUE}[INFO] {message}{Colors.RESET}")
def display_success(message: str) -> None:
"""Display success message"""
safe_print(f"{Colors.GREEN}[{symbols.checkmark}] {message}{Colors.RESET}")
def display_warning(message: str) -> None:
"""Display warning message"""
print(f"{Colors.YELLOW}[!] {message}{Colors.RESET}")
def display_error(message: str) -> None:
"""Display error message"""
safe_print(f"{Colors.RED}[{symbols.crossmark}] {message}{Colors.RESET}")
def display_step(step: int, total: int, message: str) -> None:
"""Display step progress"""
print(f"{Colors.CYAN}[{step}/{total}] {message}{Colors.RESET}")
def display_table(headers: List[str], rows: List[List[str]], title: str = "") -> None:
"""
Display data in table format
Args:
headers: Column headers
rows: Data rows
title: Optional table title
"""
if not rows:
return
# Calculate column widths
col_widths = [len(header) for header in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
# Display title
if title:
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{title}{Colors.RESET}")
print()
# Display headers
header_line = " | ".join(
f"{header:<{col_widths[i]}}" for i, header in enumerate(headers)
)
print(f"{Colors.YELLOW}{header_line}{Colors.RESET}")
print("-" * len(header_line))
# Display rows
for row in rows:
row_line = " | ".join(
f"{str(cell):<{col_widths[i]}}" for i, cell in enumerate(row)
)
print(row_line)
print()
def prompt_api_key(service_name: str, env_var_name: str) -> Optional[str]:
"""
Prompt for API key with security and UX best practices
Args:
service_name: Human-readable service name (e.g., "Magic", "Morphllm")
env_var_name: Environment variable name (e.g., "TWENTYFIRST_API_KEY")
Returns:
API key string if provided, None if skipped
"""
print(
f"{Colors.BLUE}[API KEY] {service_name} requires: {Colors.BRIGHT}{env_var_name}{Colors.RESET}"
)
print(
f"{Colors.WHITE}Visit the service documentation to obtain your API key{Colors.RESET}"
)
print(
f"{Colors.YELLOW}Press Enter to skip (you can set this manually later){Colors.RESET}"
)
try:
# Use getpass for hidden input
api_key = getpass.getpass(f"Enter {env_var_name}: ").strip()
if not api_key:
print(
f"{Colors.YELLOW}[SKIPPED] {env_var_name} - set manually later{Colors.RESET}"
)
return None
# Basic validation (non-empty, reasonable length)
if len(api_key) < 10:
print(
f"{Colors.RED}[WARNING] API key seems too short. Continue anyway? (y/N){Colors.RESET}"
)
if not confirm("", default=False):
return None
safe_print(
f"{Colors.GREEN}[{symbols.checkmark}] {env_var_name} configured{Colors.RESET}"
)
return api_key
except KeyboardInterrupt:
safe_print(f"\n{Colors.YELLOW}[SKIPPED] {env_var_name}{Colors.RESET}")
return None
def wait_for_key(message: str = "Press Enter to continue...") -> None:
"""Wait for user to press a key"""
try:
input(f"{Colors.BLUE}{message}{Colors.RESET}")
except KeyboardInterrupt:
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
def clear_screen() -> None:
"""Clear terminal screen"""
import os
os.system("cls" if os.name == "nt" else "clear")
class StatusSpinner:
"""Simple status spinner for long operations"""
def __init__(self, message: str = "Working..."):
"""
Initialize spinner
Args:
message: Message to display with spinner
"""
self.message = message
self.spinning = False
self.chars = symbols.spinner_chars
def __init__(self, total: int, prefix: str = "", suffix: str = ""):
self.total = total
self.prefix = prefix
self.suffix = suffix
self.current = 0
def start(self) -> None:
"""Start spinner in background thread"""
import threading
def update(self, current: int = None, message: str = None) -> None:
"""Update progress"""
if current is not None:
self.current = current
else:
self.current += 1
def spin():
while self.spinning:
char = self.chars[self.current % len(self.chars)]
safe_print(
f"\r{Colors.BLUE}{char} {self.message}{Colors.RESET}",
end="",
flush=True,
)
self.current += 1
time.sleep(0.1)
percent = int((self.current / self.total) * 100) if self.total > 0 else 100
display_msg = message or f"{self.prefix}{self.current}/{self.total} {self.suffix}"
print(f"\r{display_msg} {percent}%", end="", flush=True)
self.spinning = True
self.thread = threading.Thread(target=spin, daemon=True)
self.thread.start()
if self.current >= self.total:
print() # New line when complete
def stop(self, final_message: str = "") -> None:
"""
Stop spinner
def finish(self, message: str = "Complete") -> None:
"""Finish progress bar"""
self.current = self.total
print(f"\r{message} 100%")
Args:
final_message: Final message to display
"""
self.spinning = False
if hasattr(self, "thread"):
self.thread.join(timeout=0.2)
# Clear spinner line
safe_print(f"\r{' ' * (len(self.message) + 5)}\r", end="")
if final_message:
safe_print(final_message)
def close(self) -> None:
"""Close progress bar"""
if self.current < self.total:
print()
def format_size(size_bytes: int) -> str:
"""Format file size in human-readable format"""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} PB"
def format_size(size: int) -> str:
"""
Format size in bytes to human-readable string
Args:
size: Size in bytes
def format_duration(seconds: float) -> str:
"""Format duration in human-readable format"""
if seconds < 1:
return f"{seconds*1000:.0f}ms"
elif seconds < 60:
return f"{seconds:.1f}s"
elif seconds < 3600:
minutes = seconds // 60
secs = seconds % 60
return f"{minutes:.0f}m {secs:.0f}s"
Returns:
Formatted size string (e.g., "1.5 MB", "256 KB")
"""
if size < 1024:
return f"{size} B"
elif size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
elif size < 1024 * 1024 * 1024:
return f"{size / (1024 * 1024):.1f} MB"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{hours:.0f}h {minutes:.0f}m"
return f"{size / (1024 * 1024 * 1024):.1f} GB"
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
"""Truncate text to maximum length with optional suffix"""
if len(text) <= max_length:
return text
def prompt_api_key(service_name: str, env_var_name: str) -> str:
"""
Prompt user for API key
return text[: max_length - len(suffix)] + suffix
Args:
service_name: Name of the service requiring the key
env_var_name: Environment variable name for the key
Returns:
API key string (empty if user skips)
"""
print(f"\n{Colors.CYAN}{service_name} API Key{Colors.RESET}")
print(f"{Colors.DIM}Environment variable: {env_var_name}{Colors.RESET}")
print(f"{Colors.YELLOW}Press Enter to skip{Colors.RESET}")
try:
# Use getpass for password-like input (hidden)
import getpass
key = getpass.getpass("Enter API key: ").strip()
return key
except (EOFError, KeyboardInterrupt):
print(f"\n{Colors.YELLOW}Skipped{Colors.RESET}")
return ""