chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# SuperClaude PyPI Publishing Scripts
|
||||
|
||||
This directory contains scripts for building and publishing SuperClaude to PyPI.
|
||||
|
||||
## Scripts
|
||||
|
||||
### `publish.sh` - Main Publishing Script
|
||||
Easy-to-use shell script for common publishing tasks:
|
||||
|
||||
```bash
|
||||
# Test upload to TestPyPI
|
||||
./scripts/publish.sh test
|
||||
|
||||
# Test installation from TestPyPI
|
||||
./scripts/publish.sh test-install
|
||||
|
||||
# Production upload to PyPI
|
||||
./scripts/publish.sh prod
|
||||
|
||||
# Build package only
|
||||
./scripts/publish.sh build
|
||||
|
||||
# Clean build artifacts
|
||||
./scripts/publish.sh clean
|
||||
|
||||
# Validate project structure
|
||||
./scripts/publish.sh check
|
||||
```
|
||||
|
||||
### `build_and_upload.py` - Advanced Build Script
|
||||
Python script with detailed control over the build and upload process:
|
||||
|
||||
```bash
|
||||
# Build and upload to TestPyPI
|
||||
python scripts/build_and_upload.py --testpypi
|
||||
|
||||
# Test installation from TestPyPI
|
||||
python scripts/build_and_upload.py --testpypi --test-install
|
||||
|
||||
# Production upload (with confirmation)
|
||||
python scripts/build_and_upload.py
|
||||
|
||||
# Skip validation (for faster builds)
|
||||
python scripts/build_and_upload.py --skip-validation --testpypi
|
||||
|
||||
# Clean only
|
||||
python scripts/build_and_upload.py --clean
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **PyPI Account**: Register at https://pypi.org/account/register/
|
||||
2. **API Tokens**: Generate tokens at https://pypi.org/manage/account/
|
||||
3. **Configuration**: Create `~/.pypirc`:
|
||||
```ini
|
||||
[pypi]
|
||||
username = __token__
|
||||
password = pypi-[your-production-token]
|
||||
|
||||
[testpypi]
|
||||
repository = https://test.pypi.org/legacy/
|
||||
username = __token__
|
||||
password = pypi-[your-test-token]
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
The `.github/workflows/publish-pypi.yml` workflow automates publishing:
|
||||
|
||||
- **Automatic**: Publishes to PyPI when a GitHub release is created
|
||||
- **Manual**: Can be triggered manually for TestPyPI uploads
|
||||
- **Validation**: Includes package validation and installation testing
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Set these in your GitHub repository settings → Secrets and variables → Actions:
|
||||
|
||||
- `PYPI_API_TOKEN`: Production PyPI token
|
||||
- `TEST_PYPI_API_TOKEN`: TestPyPI token
|
||||
|
||||
## Publishing Workflow
|
||||
|
||||
### 1. Development Release (TestPyPI)
|
||||
```bash
|
||||
# Test the build and upload process
|
||||
./scripts/publish.sh test
|
||||
|
||||
# Verify the package installs correctly
|
||||
./scripts/publish.sh test-install
|
||||
```
|
||||
|
||||
### 2. Production Release (PyPI)
|
||||
|
||||
#### Option A: Manual
|
||||
```bash
|
||||
# Upload directly (requires confirmation)
|
||||
./scripts/publish.sh prod
|
||||
```
|
||||
|
||||
#### Option B: GitHub Release (Recommended)
|
||||
1. Update version in code
|
||||
2. Commit and push changes
|
||||
3. Create a new release on GitHub
|
||||
4. GitHub Actions will automatically build and publish
|
||||
|
||||
## Version Management
|
||||
|
||||
Before publishing, ensure version consistency across:
|
||||
- `pyproject.toml`
|
||||
- `superclaude/__init__.py`
|
||||
- `superclaude/__main__.py`
|
||||
- `setup/__init__.py`
|
||||
|
||||
The build script validates version consistency automatically.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
- Check Python version compatibility (≥3.8)
|
||||
- Ensure all required files are present
|
||||
- Validate `pyproject.toml` syntax
|
||||
|
||||
### Upload Failures
|
||||
- Verify API tokens are correct
|
||||
- Check if version already exists on PyPI
|
||||
- Ensure package name is available
|
||||
|
||||
### Import Failures
|
||||
- Check package structure (`__init__.py` files)
|
||||
- Verify all dependencies are listed
|
||||
- Test local installation first
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit API tokens to version control
|
||||
- Use environment variables or `.pypirc` for credentials
|
||||
- Tokens should have minimal required permissions
|
||||
- Consider using Trusted Publishing for GitHub Actions
|
||||
Executable
+310
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A/B Testing Framework for Workflow Variants
|
||||
|
||||
Compares two workflow variants with statistical significance testing.
|
||||
|
||||
Usage:
|
||||
python scripts/ab_test_workflows.py \\
|
||||
--variant-a progressive_v3_layer2 \\
|
||||
--variant-b experimental_eager_layer3 \\
|
||||
--metric tokens_used
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from scipy import stats
|
||||
|
||||
|
||||
class ABTestAnalyzer:
|
||||
"""A/B testing framework for workflow optimization"""
|
||||
|
||||
def __init__(self, metrics_file: Path):
|
||||
self.metrics_file = metrics_file
|
||||
self.metrics: List[Dict] = []
|
||||
self._load_metrics()
|
||||
|
||||
def _load_metrics(self):
|
||||
"""Load metrics from JSONL file"""
|
||||
if not self.metrics_file.exists():
|
||||
print(f"Error: {self.metrics_file} not found")
|
||||
return
|
||||
|
||||
with open(self.metrics_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
self.metrics.append(json.loads(line))
|
||||
|
||||
def get_variant_metrics(self, workflow_id: str) -> List[Dict]:
|
||||
"""Get all metrics for a specific workflow variant"""
|
||||
return [m for m in self.metrics if m['workflow_id'] == workflow_id]
|
||||
|
||||
def extract_metric_values(self, metrics: List[Dict], metric: str) -> List[float]:
|
||||
"""Extract specific metric values from metrics list"""
|
||||
values = []
|
||||
for m in metrics:
|
||||
if metric in m:
|
||||
value = m[metric]
|
||||
# Handle boolean metrics
|
||||
if isinstance(value, bool):
|
||||
value = 1.0 if value else 0.0
|
||||
values.append(float(value))
|
||||
return values
|
||||
|
||||
def calculate_statistics(self, values: List[float]) -> Dict:
|
||||
"""Calculate statistical measures"""
|
||||
if not values:
|
||||
return {
|
||||
'count': 0,
|
||||
'mean': 0,
|
||||
'median': 0,
|
||||
'stdev': 0,
|
||||
'min': 0,
|
||||
'max': 0
|
||||
}
|
||||
|
||||
return {
|
||||
'count': len(values),
|
||||
'mean': statistics.mean(values),
|
||||
'median': statistics.median(values),
|
||||
'stdev': statistics.stdev(values) if len(values) > 1 else 0,
|
||||
'min': min(values),
|
||||
'max': max(values)
|
||||
}
|
||||
|
||||
def perform_ttest(
|
||||
self,
|
||||
variant_a_values: List[float],
|
||||
variant_b_values: List[float]
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Perform independent t-test between two variants.
|
||||
|
||||
Returns:
|
||||
(t_statistic, p_value)
|
||||
"""
|
||||
if len(variant_a_values) < 2 or len(variant_b_values) < 2:
|
||||
return 0.0, 1.0 # Not enough data
|
||||
|
||||
t_stat, p_value = stats.ttest_ind(variant_a_values, variant_b_values)
|
||||
return t_stat, p_value
|
||||
|
||||
def determine_winner(
|
||||
self,
|
||||
variant_a_stats: Dict,
|
||||
variant_b_stats: Dict,
|
||||
p_value: float,
|
||||
metric: str,
|
||||
lower_is_better: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Determine winning variant based on statistics.
|
||||
|
||||
Args:
|
||||
variant_a_stats: Statistics for variant A
|
||||
variant_b_stats: Statistics for variant B
|
||||
p_value: Statistical significance (p-value)
|
||||
metric: Metric being compared
|
||||
lower_is_better: True if lower values are better (e.g., tokens_used)
|
||||
|
||||
Returns:
|
||||
Winner description
|
||||
"""
|
||||
# Require statistical significance (p < 0.05)
|
||||
if p_value >= 0.05:
|
||||
return "No significant difference (p ≥ 0.05)"
|
||||
|
||||
# Require minimum sample size (20 trials per variant)
|
||||
if variant_a_stats['count'] < 20 or variant_b_stats['count'] < 20:
|
||||
return f"Insufficient data (need 20 trials, have {variant_a_stats['count']}/{variant_b_stats['count']})"
|
||||
|
||||
# Compare means
|
||||
a_mean = variant_a_stats['mean']
|
||||
b_mean = variant_b_stats['mean']
|
||||
|
||||
if lower_is_better:
|
||||
if a_mean < b_mean:
|
||||
improvement = ((b_mean - a_mean) / b_mean) * 100
|
||||
return f"Variant A wins ({improvement:.1f}% better)"
|
||||
else:
|
||||
improvement = ((a_mean - b_mean) / a_mean) * 100
|
||||
return f"Variant B wins ({improvement:.1f}% better)"
|
||||
else:
|
||||
if a_mean > b_mean:
|
||||
improvement = ((a_mean - b_mean) / b_mean) * 100
|
||||
return f"Variant A wins ({improvement:.1f}% better)"
|
||||
else:
|
||||
improvement = ((b_mean - a_mean) / a_mean) * 100
|
||||
return f"Variant B wins ({improvement:.1f}% better)"
|
||||
|
||||
def generate_recommendation(
|
||||
self,
|
||||
winner: str,
|
||||
variant_a_stats: Dict,
|
||||
variant_b_stats: Dict,
|
||||
p_value: float
|
||||
) -> str:
|
||||
"""Generate actionable recommendation"""
|
||||
if "No significant difference" in winner:
|
||||
return "⚖️ Keep current workflow (no improvement detected)"
|
||||
|
||||
if "Insufficient data" in winner:
|
||||
return "📊 Continue testing (need more trials)"
|
||||
|
||||
if "Variant A wins" in winner:
|
||||
return "✅ Keep Variant A as standard (statistically better)"
|
||||
|
||||
if "Variant B wins" in winner:
|
||||
if variant_b_stats['mean'] > variant_a_stats['mean'] * 0.8: # At least 20% better
|
||||
return "🚀 Promote Variant B to standard (significant improvement)"
|
||||
else:
|
||||
return "⚠️ Marginal improvement - continue testing before promotion"
|
||||
|
||||
return "🤔 Manual review recommended"
|
||||
|
||||
def compare_variants(
|
||||
self,
|
||||
variant_a_id: str,
|
||||
variant_b_id: str,
|
||||
metric: str = 'tokens_used',
|
||||
lower_is_better: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Compare two workflow variants on a specific metric.
|
||||
|
||||
Args:
|
||||
variant_a_id: Workflow ID for variant A
|
||||
variant_b_id: Workflow ID for variant B
|
||||
metric: Metric to compare (default: tokens_used)
|
||||
lower_is_better: True if lower values are better
|
||||
|
||||
Returns:
|
||||
Comparison report
|
||||
"""
|
||||
# Get metrics for each variant
|
||||
variant_a_metrics = self.get_variant_metrics(variant_a_id)
|
||||
variant_b_metrics = self.get_variant_metrics(variant_b_id)
|
||||
|
||||
if not variant_a_metrics:
|
||||
return f"Error: No data for variant A ({variant_a_id})"
|
||||
if not variant_b_metrics:
|
||||
return f"Error: No data for variant B ({variant_b_id})"
|
||||
|
||||
# Extract metric values
|
||||
a_values = self.extract_metric_values(variant_a_metrics, metric)
|
||||
b_values = self.extract_metric_values(variant_b_metrics, metric)
|
||||
|
||||
# Calculate statistics
|
||||
a_stats = self.calculate_statistics(a_values)
|
||||
b_stats = self.calculate_statistics(b_values)
|
||||
|
||||
# Perform t-test
|
||||
t_stat, p_value = self.perform_ttest(a_values, b_values)
|
||||
|
||||
# Determine winner
|
||||
winner = self.determine_winner(a_stats, b_stats, p_value, metric, lower_is_better)
|
||||
|
||||
# Generate recommendation
|
||||
recommendation = self.generate_recommendation(winner, a_stats, b_stats, p_value)
|
||||
|
||||
# Format report
|
||||
report = []
|
||||
report.append("=" * 80)
|
||||
report.append("A/B TEST COMPARISON REPORT")
|
||||
report.append("=" * 80)
|
||||
report.append("")
|
||||
report.append(f"Metric: {metric}")
|
||||
report.append(f"Better: {'Lower' if lower_is_better else 'Higher'} values")
|
||||
report.append("")
|
||||
|
||||
report.append(f"## Variant A: {variant_a_id}")
|
||||
report.append(f" Trials: {a_stats['count']}")
|
||||
report.append(f" Mean: {a_stats['mean']:.2f}")
|
||||
report.append(f" Median: {a_stats['median']:.2f}")
|
||||
report.append(f" Std Dev: {a_stats['stdev']:.2f}")
|
||||
report.append(f" Range: {a_stats['min']:.2f} - {a_stats['max']:.2f}")
|
||||
report.append("")
|
||||
|
||||
report.append(f"## Variant B: {variant_b_id}")
|
||||
report.append(f" Trials: {b_stats['count']}")
|
||||
report.append(f" Mean: {b_stats['mean']:.2f}")
|
||||
report.append(f" Median: {b_stats['median']:.2f}")
|
||||
report.append(f" Std Dev: {b_stats['stdev']:.2f}")
|
||||
report.append(f" Range: {b_stats['min']:.2f} - {b_stats['max']:.2f}")
|
||||
report.append("")
|
||||
|
||||
report.append("## Statistical Significance")
|
||||
report.append(f" t-statistic: {t_stat:.4f}")
|
||||
report.append(f" p-value: {p_value:.4f}")
|
||||
if p_value < 0.01:
|
||||
report.append(" Significance: *** (p < 0.01) - Highly significant")
|
||||
elif p_value < 0.05:
|
||||
report.append(" Significance: ** (p < 0.05) - Significant")
|
||||
elif p_value < 0.10:
|
||||
report.append(" Significance: * (p < 0.10) - Marginally significant")
|
||||
else:
|
||||
report.append(" Significance: n.s. (p ≥ 0.10) - Not significant")
|
||||
report.append("")
|
||||
|
||||
report.append(f"## Result: {winner}")
|
||||
report.append(f"## Recommendation: {recommendation}")
|
||||
report.append("")
|
||||
report.append("=" * 80)
|
||||
|
||||
return "\n".join(report)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="A/B test workflow variants")
|
||||
parser.add_argument(
|
||||
'--variant-a',
|
||||
required=True,
|
||||
help='Workflow ID for variant A'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--variant-b',
|
||||
required=True,
|
||||
help='Workflow ID for variant B'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--metric',
|
||||
default='tokens_used',
|
||||
help='Metric to compare (default: tokens_used)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--higher-is-better',
|
||||
action='store_true',
|
||||
help='Higher values are better (default: lower is better)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
help='Output file (default: stdout)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find metrics file
|
||||
metrics_file = Path('docs/memory/workflow_metrics.jsonl')
|
||||
|
||||
analyzer = ABTestAnalyzer(metrics_file)
|
||||
report = analyzer.compare_variants(
|
||||
args.variant_a,
|
||||
args.variant_b,
|
||||
args.metric,
|
||||
lower_is_better=not args.higher_is_better
|
||||
)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(report)
|
||||
print(f"Report written to {args.output}")
|
||||
else:
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Workflow Metrics Analysis Script
|
||||
|
||||
Analyzes workflow_metrics.jsonl for continuous optimization and A/B testing.
|
||||
|
||||
Usage:
|
||||
python scripts/analyze_workflow_metrics.py --period week
|
||||
python scripts/analyze_workflow_metrics.py --period month
|
||||
python scripts/analyze_workflow_metrics.py --task-type bug_fix
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class WorkflowMetricsAnalyzer:
|
||||
"""Analyze workflow metrics for optimization"""
|
||||
|
||||
def __init__(self, metrics_file: Path):
|
||||
self.metrics_file = metrics_file
|
||||
self.metrics: List[Dict] = []
|
||||
self._load_metrics()
|
||||
|
||||
def _load_metrics(self):
|
||||
"""Load metrics from JSONL file"""
|
||||
if not self.metrics_file.exists():
|
||||
print(f"Warning: {self.metrics_file} not found")
|
||||
return
|
||||
|
||||
with open(self.metrics_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
self.metrics.append(json.loads(line))
|
||||
|
||||
print(f"Loaded {len(self.metrics)} metric records")
|
||||
|
||||
def filter_by_period(self, period: str) -> List[Dict]:
|
||||
"""Filter metrics by time period"""
|
||||
now = datetime.now()
|
||||
|
||||
if period == "week":
|
||||
cutoff = now - timedelta(days=7)
|
||||
elif period == "month":
|
||||
cutoff = now - timedelta(days=30)
|
||||
elif period == "all":
|
||||
return self.metrics
|
||||
else:
|
||||
raise ValueError(f"Invalid period: {period}")
|
||||
|
||||
filtered = [
|
||||
m for m in self.metrics
|
||||
if datetime.fromisoformat(m['timestamp']) >= cutoff
|
||||
]
|
||||
|
||||
print(f"Filtered to {len(filtered)} records in last {period}")
|
||||
return filtered
|
||||
|
||||
def analyze_by_task_type(self, metrics: List[Dict]) -> Dict:
|
||||
"""Analyze metrics grouped by task type"""
|
||||
by_task = defaultdict(list)
|
||||
|
||||
for m in metrics:
|
||||
by_task[m['task_type']].append(m)
|
||||
|
||||
results = {}
|
||||
for task_type, task_metrics in by_task.items():
|
||||
results[task_type] = {
|
||||
'count': len(task_metrics),
|
||||
'avg_tokens': statistics.mean(m['tokens_used'] for m in task_metrics),
|
||||
'avg_time_ms': statistics.mean(m['time_ms'] for m in task_metrics),
|
||||
'success_rate': sum(m['success'] for m in task_metrics) / len(task_metrics) * 100,
|
||||
'avg_files_read': statistics.mean(m.get('files_read', 0) for m in task_metrics),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def analyze_by_complexity(self, metrics: List[Dict]) -> Dict:
|
||||
"""Analyze metrics grouped by complexity level"""
|
||||
by_complexity = defaultdict(list)
|
||||
|
||||
for m in metrics:
|
||||
by_complexity[m['complexity']].append(m)
|
||||
|
||||
results = {}
|
||||
for complexity, comp_metrics in by_complexity.items():
|
||||
results[complexity] = {
|
||||
'count': len(comp_metrics),
|
||||
'avg_tokens': statistics.mean(m['tokens_used'] for m in comp_metrics),
|
||||
'avg_time_ms': statistics.mean(m['time_ms'] for m in comp_metrics),
|
||||
'success_rate': sum(m['success'] for m in comp_metrics) / len(comp_metrics) * 100,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def analyze_by_workflow(self, metrics: List[Dict]) -> Dict:
|
||||
"""Analyze metrics grouped by workflow variant"""
|
||||
by_workflow = defaultdict(list)
|
||||
|
||||
for m in metrics:
|
||||
by_workflow[m['workflow_id']].append(m)
|
||||
|
||||
results = {}
|
||||
for workflow_id, wf_metrics in by_workflow.items():
|
||||
results[workflow_id] = {
|
||||
'count': len(wf_metrics),
|
||||
'avg_tokens': statistics.mean(m['tokens_used'] for m in wf_metrics),
|
||||
'median_tokens': statistics.median(m['tokens_used'] for m in wf_metrics),
|
||||
'avg_time_ms': statistics.mean(m['time_ms'] for m in wf_metrics),
|
||||
'success_rate': sum(m['success'] for m in wf_metrics) / len(wf_metrics) * 100,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def identify_best_workflows(self, metrics: List[Dict]) -> Dict[str, str]:
|
||||
"""Identify best workflow for each task type"""
|
||||
by_task_workflow = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
for m in metrics:
|
||||
by_task_workflow[m['task_type']][m['workflow_id']].append(m)
|
||||
|
||||
best_workflows = {}
|
||||
for task_type, workflows in by_task_workflow.items():
|
||||
best_workflow = None
|
||||
best_score = float('inf')
|
||||
|
||||
for workflow_id, wf_metrics in workflows.items():
|
||||
# Score = avg_tokens (lower is better)
|
||||
avg_tokens = statistics.mean(m['tokens_used'] for m in wf_metrics)
|
||||
success_rate = sum(m['success'] for m in wf_metrics) / len(wf_metrics)
|
||||
|
||||
# Only consider if success rate >= 95%
|
||||
if success_rate >= 0.95:
|
||||
if avg_tokens < best_score:
|
||||
best_score = avg_tokens
|
||||
best_workflow = workflow_id
|
||||
|
||||
if best_workflow:
|
||||
best_workflows[task_type] = best_workflow
|
||||
|
||||
return best_workflows
|
||||
|
||||
def identify_inefficiencies(self, metrics: List[Dict]) -> List[Dict]:
|
||||
"""Identify inefficient patterns"""
|
||||
inefficiencies = []
|
||||
|
||||
# Expected token budgets by complexity
|
||||
budgets = {
|
||||
'ultra-light': 800,
|
||||
'light': 2000,
|
||||
'medium': 5000,
|
||||
'heavy': 20000,
|
||||
'ultra-heavy': 50000
|
||||
}
|
||||
|
||||
for m in metrics:
|
||||
issues = []
|
||||
|
||||
# Check token budget overrun
|
||||
expected_budget = budgets.get(m['complexity'], 5000)
|
||||
if m['tokens_used'] > expected_budget * 1.3: # 30% over budget
|
||||
issues.append(f"Token overrun: {m['tokens_used']} vs {expected_budget}")
|
||||
|
||||
# Check success rate
|
||||
if not m['success']:
|
||||
issues.append("Task failed")
|
||||
|
||||
# Check time performance (light tasks should be fast)
|
||||
if m['complexity'] in ['ultra-light', 'light'] and m['time_ms'] > 10000:
|
||||
issues.append(f"Slow execution: {m['time_ms']}ms for {m['complexity']} task")
|
||||
|
||||
if issues:
|
||||
inefficiencies.append({
|
||||
'timestamp': m['timestamp'],
|
||||
'task_type': m['task_type'],
|
||||
'complexity': m['complexity'],
|
||||
'workflow_id': m['workflow_id'],
|
||||
'issues': issues
|
||||
})
|
||||
|
||||
return inefficiencies
|
||||
|
||||
def calculate_token_savings(self, metrics: List[Dict]) -> Dict:
|
||||
"""Calculate token savings vs unlimited baseline"""
|
||||
# Unlimited baseline estimates
|
||||
baseline = {
|
||||
'ultra-light': 1000,
|
||||
'light': 2500,
|
||||
'medium': 7500,
|
||||
'heavy': 30000,
|
||||
'ultra-heavy': 100000
|
||||
}
|
||||
|
||||
total_actual = 0
|
||||
total_baseline = 0
|
||||
|
||||
for m in metrics:
|
||||
total_actual += m['tokens_used']
|
||||
total_baseline += baseline.get(m['complexity'], 7500)
|
||||
|
||||
savings = total_baseline - total_actual
|
||||
savings_percent = (savings / total_baseline * 100) if total_baseline > 0 else 0
|
||||
|
||||
return {
|
||||
'total_actual': total_actual,
|
||||
'total_baseline': total_baseline,
|
||||
'total_savings': savings,
|
||||
'savings_percent': savings_percent
|
||||
}
|
||||
|
||||
def generate_report(self, period: str) -> str:
|
||||
"""Generate comprehensive analysis report"""
|
||||
metrics = self.filter_by_period(period)
|
||||
|
||||
if not metrics:
|
||||
return "No metrics available for analysis"
|
||||
|
||||
report = []
|
||||
report.append("=" * 80)
|
||||
report.append(f"WORKFLOW METRICS ANALYSIS REPORT - Last {period}")
|
||||
report.append("=" * 80)
|
||||
report.append("")
|
||||
|
||||
# Overall statistics
|
||||
report.append("## Overall Statistics")
|
||||
report.append(f"Total Tasks: {len(metrics)}")
|
||||
report.append(f"Success Rate: {sum(m['success'] for m in metrics) / len(metrics) * 100:.1f}%")
|
||||
report.append(f"Avg Tokens: {statistics.mean(m['tokens_used'] for m in metrics):.0f}")
|
||||
report.append(f"Avg Time: {statistics.mean(m['time_ms'] for m in metrics):.0f}ms")
|
||||
report.append("")
|
||||
|
||||
# Token savings
|
||||
savings = self.calculate_token_savings(metrics)
|
||||
report.append("## Token Efficiency")
|
||||
report.append(f"Actual Usage: {savings['total_actual']:,} tokens")
|
||||
report.append(f"Unlimited Baseline: {savings['total_baseline']:,} tokens")
|
||||
report.append(f"Total Savings: {savings['total_savings']:,} tokens ({savings['savings_percent']:.1f}%)")
|
||||
report.append("")
|
||||
|
||||
# By task type
|
||||
report.append("## Analysis by Task Type")
|
||||
by_task = self.analyze_by_task_type(metrics)
|
||||
for task_type, stats in sorted(by_task.items()):
|
||||
report.append(f"\n### {task_type}")
|
||||
report.append(f" Count: {stats['count']}")
|
||||
report.append(f" Avg Tokens: {stats['avg_tokens']:.0f}")
|
||||
report.append(f" Avg Time: {stats['avg_time_ms']:.0f}ms")
|
||||
report.append(f" Success Rate: {stats['success_rate']:.1f}%")
|
||||
report.append(f" Avg Files Read: {stats['avg_files_read']:.1f}")
|
||||
|
||||
report.append("")
|
||||
|
||||
# By complexity
|
||||
report.append("## Analysis by Complexity")
|
||||
by_complexity = self.analyze_by_complexity(metrics)
|
||||
for complexity in ['ultra-light', 'light', 'medium', 'heavy', 'ultra-heavy']:
|
||||
if complexity in by_complexity:
|
||||
stats = by_complexity[complexity]
|
||||
report.append(f"\n### {complexity}")
|
||||
report.append(f" Count: {stats['count']}")
|
||||
report.append(f" Avg Tokens: {stats['avg_tokens']:.0f}")
|
||||
report.append(f" Success Rate: {stats['success_rate']:.1f}%")
|
||||
|
||||
report.append("")
|
||||
|
||||
# Best workflows
|
||||
report.append("## Best Workflows per Task Type")
|
||||
best = self.identify_best_workflows(metrics)
|
||||
for task_type, workflow_id in sorted(best.items()):
|
||||
report.append(f" {task_type}: {workflow_id}")
|
||||
|
||||
report.append("")
|
||||
|
||||
# Inefficiencies
|
||||
inefficiencies = self.identify_inefficiencies(metrics)
|
||||
if inefficiencies:
|
||||
report.append("## Inefficiencies Detected")
|
||||
report.append(f"Total Issues: {len(inefficiencies)}")
|
||||
for issue in inefficiencies[:5]: # Show top 5
|
||||
report.append(f"\n {issue['timestamp']}")
|
||||
report.append(f" Task: {issue['task_type']} ({issue['complexity']})")
|
||||
report.append(f" Workflow: {issue['workflow_id']}")
|
||||
for problem in issue['issues']:
|
||||
report.append(f" - {problem}")
|
||||
|
||||
report.append("")
|
||||
report.append("=" * 80)
|
||||
|
||||
return "\n".join(report)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Analyze workflow metrics")
|
||||
parser.add_argument(
|
||||
'--period',
|
||||
choices=['week', 'month', 'all'],
|
||||
default='week',
|
||||
help='Analysis time period'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--task-type',
|
||||
help='Filter by specific task type'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
help='Output file (default: stdout)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find metrics file
|
||||
metrics_file = Path('docs/memory/workflow_metrics.jsonl')
|
||||
|
||||
analyzer = WorkflowMetricsAnalyzer(metrics_file)
|
||||
report = analyzer.generate_report(args.period)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(report)
|
||||
print(f"Report written to {args.output}")
|
||||
else:
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build SuperClaude plugin distribution artefacts from unified sources.
|
||||
|
||||
Usage:
|
||||
python scripts/build_superclaude_plugin.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLUGIN_SRC = ROOT / "plugins" / "superclaude"
|
||||
DIST_ROOT = ROOT / "dist" / "plugins" / "superclaude"
|
||||
MANIFEST_DIR = PLUGIN_SRC / "manifest"
|
||||
|
||||
|
||||
def load_metadata() -> dict:
|
||||
with (MANIFEST_DIR / "metadata.json").open() as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
version_file = ROOT / "VERSION"
|
||||
if version_file.exists():
|
||||
metadata["plugin_version"] = version_file.read_text().strip()
|
||||
else:
|
||||
# Fall back to metadata override or default version
|
||||
metadata["plugin_version"] = metadata.get("plugin_version", "0.0.0")
|
||||
|
||||
metadata.setdefault("keywords", [])
|
||||
return metadata
|
||||
|
||||
|
||||
def render_template(template_path: Path, placeholders: dict[str, str]) -> str:
|
||||
content = template_path.read_text()
|
||||
for key, value in placeholders.items():
|
||||
token = f"{{{{{key}}}}}"
|
||||
content = content.replace(token, value)
|
||||
return content
|
||||
|
||||
|
||||
def copy_tree(src: Path, dest: Path) -> None:
|
||||
if not src.exists():
|
||||
return
|
||||
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not PLUGIN_SRC.exists():
|
||||
raise SystemExit(f"Missing plugin sources: {PLUGIN_SRC}")
|
||||
|
||||
metadata = load_metadata()
|
||||
|
||||
placeholders = {
|
||||
"plugin_name": metadata["plugin_name"],
|
||||
"plugin_version": metadata["plugin_version"],
|
||||
"plugin_description": metadata["plugin_description"],
|
||||
"author_name": metadata["author_name"],
|
||||
"homepage_url": metadata["homepage_url"],
|
||||
"repository_url": metadata["repository_url"],
|
||||
"license": metadata["license"],
|
||||
"keywords_json": json.dumps(metadata["keywords"]),
|
||||
"marketplace_name": metadata["marketplace_name"],
|
||||
"marketplace_description": metadata["marketplace_description"],
|
||||
}
|
||||
|
||||
# Clean dist directory
|
||||
if DIST_ROOT.exists():
|
||||
shutil.rmtree(DIST_ROOT)
|
||||
DIST_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy top-level asset directories
|
||||
for folder in ["agents", "commands", "hooks", "scripts", "skills"]:
|
||||
copy_tree(PLUGIN_SRC / folder, DIST_ROOT / folder)
|
||||
|
||||
# Render manifests
|
||||
claude_dir = DIST_ROOT / ".claude-plugin"
|
||||
claude_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
plugin_manifest = render_template(MANIFEST_DIR / "plugin.template.json", placeholders)
|
||||
(claude_dir / "plugin.json").write_text(plugin_manifest + "\n")
|
||||
|
||||
marketplace_manifest = render_template(MANIFEST_DIR / "marketplace.template.json", placeholders)
|
||||
(claude_dir / "marketplace.json").write_text(marketplace_manifest + "\n")
|
||||
|
||||
# Copy tests into manifest directory
|
||||
tests_src = PLUGIN_SRC / "tests"
|
||||
if tests_src.exists():
|
||||
copy_tree(tests_src, claude_dir / "tests")
|
||||
|
||||
print(f"✅ Built plugin artefacts at {DIST_ROOT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
# SuperClaude Project Cleanup Script
|
||||
# Removes build artifacts, cache files, and temporary files
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo -e "${BLUE}🧹 SuperClaude Project Cleanup${NC}"
|
||||
echo -e "📁 Project root: $PROJECT_ROOT"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Function to safely remove files/directories
|
||||
safe_remove() {
|
||||
local target="$1"
|
||||
local description="$2"
|
||||
|
||||
if [ -e "$target" ]; then
|
||||
rm -rf "$target"
|
||||
echo -e "${GREEN}✅ Removed $description${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}ℹ️ $description not found (already clean)${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "\n${YELLOW}🗑️ Removing Python cache files...${NC}"
|
||||
find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name "*.pyc" -type f -delete 2>/dev/null || true
|
||||
find . -name "*.pyo" -type f -delete 2>/dev/null || true
|
||||
find . -name "*.pyd" -type f -delete 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ Python cache files cleaned${NC}"
|
||||
|
||||
echo -e "\n${YELLOW}📦 Removing build artifacts...${NC}"
|
||||
safe_remove "build/" "Build directory"
|
||||
safe_remove "dist/" "Distribution directory"
|
||||
safe_remove "*.egg-info" "Egg-info directories"
|
||||
safe_remove ".eggs/" "Eggs directory"
|
||||
safe_remove "wheels/" "Wheels directory"
|
||||
safe_remove "pip-wheel-metadata/" "Pip wheel metadata"
|
||||
|
||||
echo -e "\n${YELLOW}🧪 Removing test artifacts...${NC}"
|
||||
safe_remove ".pytest_cache/" "Pytest cache"
|
||||
safe_remove ".tox/" "Tox directory"
|
||||
safe_remove ".nox/" "Nox directory"
|
||||
safe_remove "htmlcov/" "HTML coverage reports"
|
||||
safe_remove ".coverage" "Coverage data file"
|
||||
safe_remove "coverage.xml" "Coverage XML report"
|
||||
safe_remove ".hypothesis/" "Hypothesis directory"
|
||||
|
||||
echo -e "\n${YELLOW}🔧 Removing development tool cache...${NC}"
|
||||
safe_remove ".mypy_cache/" "MyPy cache"
|
||||
safe_remove ".ruff_cache/" "Ruff cache"
|
||||
safe_remove ".black/" "Black cache"
|
||||
|
||||
echo -e "\n${YELLOW}🗄️ Removing temporary files...${NC}"
|
||||
find . -name "*.tmp" -type f -delete 2>/dev/null || true
|
||||
find . -name "*.temp" -type f -delete 2>/dev/null || true
|
||||
find . -name "*~" -type f -delete 2>/dev/null || true
|
||||
find . -name "*.bak" -type f -delete 2>/dev/null || true
|
||||
find . -name "*.backup" -type f -delete 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ Temporary files cleaned${NC}"
|
||||
|
||||
echo -e "\n${YELLOW}📋 Removing PyPI publishing artifacts...${NC}"
|
||||
safe_remove "twine.log" "Twine log file"
|
||||
safe_remove ".twine/" "Twine directory"
|
||||
safe_remove "PYPI_SETUP_COMPLETE.md" "Setup completion file"
|
||||
|
||||
echo -e "\n${YELLOW}🧽 Removing OS-specific files...${NC}"
|
||||
find . -name ".DS_Store" -type f -delete 2>/dev/null || true
|
||||
find . -name "._*" -type f -delete 2>/dev/null || true
|
||||
find . -name "Thumbs.db" -type f -delete 2>/dev/null || true
|
||||
find . -name "Desktop.ini" -type f -delete 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ OS-specific files cleaned${NC}"
|
||||
|
||||
echo -e "\n${GREEN}🎉 Cleanup completed successfully!${NC}"
|
||||
echo -e "${BLUE}📊 Project is clean and ready for development or publishing${NC}"
|
||||
|
||||
# Show summary
|
||||
echo -e "\n${BLUE}📈 Summary:${NC}"
|
||||
echo -e " • Python cache files: Removed"
|
||||
echo -e " • Build artifacts: Cleaned"
|
||||
echo -e " • Test artifacts: Removed"
|
||||
echo -e " • Development tool cache: Cleared"
|
||||
echo -e " • Temporary files: Deleted"
|
||||
echo -e " • PyPI artifacts: Cleaned"
|
||||
echo -e " • OS-specific files: Removed"
|
||||
|
||||
echo -e "\n${YELLOW}💡 Next steps:${NC}"
|
||||
echo -e " • Run validation: ${BLUE}python3 scripts/validate_pypi_ready.py${NC}"
|
||||
echo -e " • Test build: ${BLUE}./scripts/publish.sh build${NC}"
|
||||
echo -e " • Check git status: ${BLUE}git status${NC}"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
"""
|
||||
SuperClaude PyPI Publishing Helper Script
|
||||
Easy-to-use wrapper for the Python build and upload script
|
||||
"""
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
BUILD_SCRIPT="$SCRIPT_DIR/build_and_upload.py"
|
||||
|
||||
echo -e "${BLUE}🚀 SuperClaude PyPI Publishing Helper${NC}"
|
||||
echo -e "📁 Project root: $PROJECT_ROOT"
|
||||
|
||||
# Function to show usage
|
||||
show_usage() {
|
||||
echo -e "${YELLOW}Usage:${NC}"
|
||||
echo " $0 test - Build and upload to TestPyPI"
|
||||
echo " $0 test-install - Test installation from TestPyPI"
|
||||
echo " $0 prod - Build and upload to production PyPI"
|
||||
echo " $0 build - Only build the package"
|
||||
echo " $0 clean - Clean build artifacts"
|
||||
echo " $0 check - Validate project structure only"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Examples:${NC}"
|
||||
echo " $0 test # Upload to TestPyPI"
|
||||
echo " $0 test && $0 test-install # Upload and test"
|
||||
echo " $0 prod # Upload to production"
|
||||
}
|
||||
|
||||
# Check if Python script exists
|
||||
if [ ! -f "$BUILD_SCRIPT" ]; then
|
||||
echo -e "${RED}❌ Build script not found: $BUILD_SCRIPT${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse command
|
||||
case "${1:-}" in
|
||||
"test")
|
||||
echo -e "${YELLOW}📦 Building and uploading to TestPyPI...${NC}"
|
||||
python3 "$BUILD_SCRIPT" --testpypi
|
||||
echo -e "${GREEN}✅ Uploaded to TestPyPI! Test with:${NC}"
|
||||
echo -e " pip install --index-url https://test.pypi.org/simple/ SuperClaude"
|
||||
;;
|
||||
|
||||
"test-install")
|
||||
echo -e "${YELLOW}🧪 Testing installation from TestPyPI...${NC}"
|
||||
python3 "$BUILD_SCRIPT" --testpypi --test-install --skip-build
|
||||
;;
|
||||
|
||||
"prod"|"production")
|
||||
echo -e "${YELLOW}🚨 Building and uploading to PRODUCTION PyPI...${NC}"
|
||||
echo -e "${RED}⚠️ This cannot be undone!${NC}"
|
||||
python3 "$BUILD_SCRIPT"
|
||||
echo -e "${GREEN}✅ Uploaded to PyPI! Install with:${NC}"
|
||||
echo -e " pip install SuperClaude"
|
||||
;;
|
||||
|
||||
"build")
|
||||
echo -e "${YELLOW}🔨 Building package only...${NC}"
|
||||
python3 "$BUILD_SCRIPT" --skip-validation --testpypi --skip-upload
|
||||
echo -e "${GREEN}✅ Package built in dist/ directory${NC}"
|
||||
;;
|
||||
|
||||
"clean")
|
||||
echo -e "${YELLOW}🧹 Cleaning build artifacts...${NC}"
|
||||
python3 "$BUILD_SCRIPT" --clean
|
||||
echo -e "${GREEN}✅ Build artifacts cleaned${NC}"
|
||||
;;
|
||||
|
||||
"check"|"validate")
|
||||
echo -e "${YELLOW}🔍 Validating project structure...${NC}"
|
||||
python3 "$BUILD_SCRIPT" --skip-build --testpypi
|
||||
;;
|
||||
|
||||
"help"|"-h"|"--help"|"")
|
||||
show_usage
|
||||
;;
|
||||
|
||||
*)
|
||||
echo -e "${RED}❌ Unknown command: $1${NC}"
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+959
@@ -0,0 +1,959 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SuperClaude Framework Sync Script
|
||||
Automated pull-sync with namespace isolation for Plugin distribution
|
||||
|
||||
This script synchronizes content from SuperClaude_Framework repository and
|
||||
transforms it for distribution as a Claude Code plugin with proper namespace
|
||||
isolation (sc: prefix for commands, sc- prefix for filenames).
|
||||
|
||||
Usage:
|
||||
python scripts/sync_from_framework.py [OPTIONS]
|
||||
|
||||
Options:
|
||||
--framework-repo URL Framework repository URL
|
||||
--plugin-root PATH Plugin repository root path
|
||||
--dry-run Preview changes without applying
|
||||
--output-report PATH Save sync report to file
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import tempfile
|
||||
import shutil
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProtectionViolationError(RuntimeError):
|
||||
"""Raised when sync would overwrite a Plugin-owned file listed in PROTECTED_PATHS."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""Results from sync operation."""
|
||||
success: bool
|
||||
timestamp: str
|
||||
framework_commit: str
|
||||
framework_version: str
|
||||
files_synced: int
|
||||
files_modified: int
|
||||
commands_transformed: int
|
||||
agents_transformed: int
|
||||
mcp_servers_merged: int
|
||||
warnings: List[str]
|
||||
errors: List[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ContentTransformer:
|
||||
"""Transforms Framework content for Plugin namespace."""
|
||||
|
||||
# Regex patterns for transformation
|
||||
COMMAND_HEADER_PATTERN = re.compile(r'^(#+\s+)/(\w+)', re.MULTILINE)
|
||||
COMMAND_REF_PATTERN = re.compile(r'(?<![/\w])/(\w+)(?=\s|$|:|`|\)|\])')
|
||||
LINK_REF_PATTERN = re.compile(r'\[/(\w+)\]')
|
||||
FRONTMATTER_NAME_PATTERN = re.compile(r'^name:\s*(.+)$', re.MULTILINE)
|
||||
|
||||
@staticmethod
|
||||
def transform_command(content: str, filename: str) -> str:
|
||||
"""
|
||||
Transform command content for sc: namespace.
|
||||
|
||||
Transformations:
|
||||
- Header: # /brainstorm → # /sc:brainstorm
|
||||
- References: /analyze → /sc:analyze
|
||||
- Links: [/task] → [/sc:task]
|
||||
|
||||
Args:
|
||||
content: Original command file content
|
||||
filename: Command filename (for logging)
|
||||
|
||||
Returns:
|
||||
Transformed content with sc: namespace
|
||||
"""
|
||||
logger.debug(f"Transforming command: {filename}")
|
||||
|
||||
# Transform main header
|
||||
content = ContentTransformer.COMMAND_HEADER_PATTERN.sub(
|
||||
r'\1/sc:\2',
|
||||
content
|
||||
)
|
||||
|
||||
# Transform command references in text
|
||||
content = ContentTransformer.COMMAND_REF_PATTERN.sub(
|
||||
r'/sc:\1',
|
||||
content
|
||||
)
|
||||
|
||||
# Transform command references in links
|
||||
content = ContentTransformer.LINK_REF_PATTERN.sub(
|
||||
r'[/sc:\1]',
|
||||
content
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def transform_agent(content: str, filename: str) -> str:
|
||||
"""
|
||||
Transform agent frontmatter name.
|
||||
|
||||
Transformations:
|
||||
- name: backend-architect → name: sc-backend-architect
|
||||
|
||||
Args:
|
||||
content: Original agent file content
|
||||
filename: Agent filename (for logging)
|
||||
|
||||
Returns:
|
||||
Transformed content with sc- prefix in name field
|
||||
"""
|
||||
logger.debug(f"Transforming agent: {filename}")
|
||||
|
||||
# Parse frontmatter
|
||||
frontmatter_pattern = re.compile(
|
||||
r'^---\n(.*?)\n---',
|
||||
re.DOTALL | re.MULTILINE
|
||||
)
|
||||
|
||||
match = frontmatter_pattern.search(content)
|
||||
if not match:
|
||||
logger.warning(f"No frontmatter found in agent: {filename}")
|
||||
return content
|
||||
|
||||
frontmatter = match.group(1)
|
||||
|
||||
# Transform name field (add sc- prefix if not already present)
|
||||
def add_prefix(match):
|
||||
name = match.group(1).strip()
|
||||
if not name.startswith('sc-'):
|
||||
return f'name: sc-{name}'
|
||||
return match.group(0)
|
||||
|
||||
frontmatter = ContentTransformer.FRONTMATTER_NAME_PATTERN.sub(
|
||||
add_prefix,
|
||||
frontmatter
|
||||
)
|
||||
|
||||
# Replace frontmatter
|
||||
content = frontmatter_pattern.sub(
|
||||
f'---\n{frontmatter}\n---',
|
||||
content,
|
||||
count=1
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
class FileSyncer:
|
||||
"""Handles file synchronization with git integration."""
|
||||
|
||||
def __init__(self, plugin_root: Path, dry_run: bool = False):
|
||||
self.plugin_root = plugin_root
|
||||
self.dry_run = dry_run
|
||||
self.git_available = self._check_git()
|
||||
|
||||
def _check_git(self) -> bool:
|
||||
"""Check if git is available and repo is initialized."""
|
||||
try:
|
||||
subprocess.run(
|
||||
['git', 'rev-parse', '--git-dir'],
|
||||
cwd=self.plugin_root,
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
logger.warning("Git not available - file operations will not preserve history")
|
||||
return False
|
||||
|
||||
def sync_directory(
|
||||
self,
|
||||
source_dir: Path,
|
||||
dest_dir: Path,
|
||||
filename_prefix: str = "",
|
||||
transform_fn=None
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Sync directory with namespace prefix and transformation.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory path
|
||||
dest_dir: Destination directory path
|
||||
filename_prefix: Prefix to add to filenames (e.g., 'sc-')
|
||||
transform_fn: Optional content transformation function
|
||||
|
||||
Returns:
|
||||
Statistics dict with counts of synced/modified files
|
||||
"""
|
||||
stats = {'synced': 0, 'modified': 0, 'renamed': 0}
|
||||
|
||||
if not source_dir.exists():
|
||||
logger.warning(f"Source directory not found: {source_dir}")
|
||||
return stats
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get existing files in dest (with sc- prefix)
|
||||
existing_files = {f.name: f for f in dest_dir.glob('*.md')}
|
||||
synced_files = set()
|
||||
|
||||
for source_file in source_dir.glob('*.md'):
|
||||
# Apply filename prefix
|
||||
new_name = f"{filename_prefix}{source_file.name}"
|
||||
synced_files.add(new_name)
|
||||
dest_file = dest_dir / new_name
|
||||
|
||||
# Read and transform content
|
||||
content = source_file.read_text(encoding='utf-8')
|
||||
if transform_fn:
|
||||
content = transform_fn(content, source_file.name)
|
||||
|
||||
# Check if file exists with different name (needs git mv)
|
||||
old_unprefixed = source_file.name
|
||||
old_file_path = dest_dir / old_unprefixed
|
||||
|
||||
if old_file_path.exists() and new_name != old_unprefixed:
|
||||
# File needs renaming: use git mv to preserve history
|
||||
if self.git_available:
|
||||
self._git_mv(old_file_path, dest_file)
|
||||
stats['renamed'] += 1
|
||||
else:
|
||||
# Fallback to regular rename
|
||||
if not self.dry_run:
|
||||
old_file_path.rename(dest_file)
|
||||
stats['renamed'] += 1
|
||||
logger.info(f" 📝 Renamed: {old_unprefixed} → {new_name}")
|
||||
|
||||
# Write content
|
||||
if not self.dry_run:
|
||||
dest_file.write_text(content, encoding='utf-8')
|
||||
|
||||
if dest_file.exists():
|
||||
stats['modified'] += 1
|
||||
else:
|
||||
stats['synced'] += 1
|
||||
|
||||
# Remove files that no longer exist in source
|
||||
# (only remove files with prefix that aren't in synced set)
|
||||
for filename, filepath in existing_files.items():
|
||||
if filename.startswith(filename_prefix) and filename not in synced_files:
|
||||
if not self.dry_run:
|
||||
filepath.unlink()
|
||||
logger.info(f" 🗑️ Removed: {filepath.relative_to(self.plugin_root)}")
|
||||
|
||||
return stats
|
||||
|
||||
def _git_mv(self, old_path: Path, new_path: Path):
|
||||
"""Use git mv to preserve history."""
|
||||
if self.dry_run:
|
||||
logger.info(f" [DRY RUN] git mv {old_path.name} {new_path.name}")
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
['git', 'mv', str(old_path), str(new_path)],
|
||||
cwd=self.plugin_root,
|
||||
check=True,
|
||||
capture_output=True
|
||||
)
|
||||
logger.info(f" 📝 Renamed (git mv): {old_path.name} → {new_path.name}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Fallback to regular rename
|
||||
logger.warning(f" ⚠️ Git mv failed, using regular rename: {e}")
|
||||
old_path.rename(new_path)
|
||||
|
||||
def copy_directory(self, source_dir: Path, dest_dir: Path) -> int:
|
||||
"""
|
||||
Copy directory contents as-is (no transformation).
|
||||
|
||||
Args:
|
||||
source_dir: Source directory path
|
||||
dest_dir: Destination directory path
|
||||
|
||||
Returns:
|
||||
Number of files copied
|
||||
"""
|
||||
if not source_dir.exists():
|
||||
logger.warning(f"Source directory not found: {source_dir}")
|
||||
return 0
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
|
||||
for source_file in source_dir.glob('**/*'):
|
||||
if source_file.is_file():
|
||||
rel_path = source_file.relative_to(source_dir)
|
||||
dest_file = dest_dir / rel_path
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self.dry_run:
|
||||
shutil.copy2(source_file, dest_file)
|
||||
|
||||
count += 1
|
||||
logger.debug(f" 📄 Copied: {rel_path}")
|
||||
|
||||
return count
|
||||
|
||||
|
||||
class PluginJsonGenerator:
|
||||
"""Generates .claude-plugin/plugin.json from synced commands."""
|
||||
|
||||
def __init__(self, plugin_root: Path):
|
||||
self.plugin_root = plugin_root
|
||||
|
||||
def generate(self, framework_version: str) -> dict:
|
||||
"""
|
||||
Generate plugin.json with command mappings.
|
||||
|
||||
Args:
|
||||
framework_version: Version from Framework repository
|
||||
|
||||
Returns:
|
||||
Complete plugin.json dictionary
|
||||
"""
|
||||
commands_dir = self.plugin_root / 'commands'
|
||||
|
||||
# Base metadata from existing plugin.json
|
||||
root_plugin_json = self.plugin_root / 'plugin.json'
|
||||
if root_plugin_json.exists():
|
||||
base_metadata = json.loads(root_plugin_json.read_text())
|
||||
else:
|
||||
base_metadata = {
|
||||
"name": "sc",
|
||||
"description": "SuperClaude Plugin",
|
||||
"author": {"name": "SuperClaude Team"},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
# Build command mappings
|
||||
commands = {}
|
||||
if commands_dir.exists():
|
||||
for cmd_file in sorted(commands_dir.glob('sc-*.md')):
|
||||
# Extract command name from filename
|
||||
# sc-brainstorm.md → brainstorm
|
||||
cmd_name = cmd_file.stem.replace('sc-', '')
|
||||
|
||||
# Map sc:brainstorm to path
|
||||
commands[f"sc:{cmd_name}"] = f"commands/{cmd_file.name}"
|
||||
|
||||
plugin_json = {
|
||||
"name": "sc",
|
||||
"version": framework_version,
|
||||
"description": base_metadata.get("description", ""),
|
||||
"author": base_metadata.get("author", {}),
|
||||
"homepage": base_metadata.get("homepage", ""),
|
||||
"repository": base_metadata.get("repository", ""),
|
||||
"license": base_metadata.get("license", "MIT"),
|
||||
"keywords": base_metadata.get("keywords", [])
|
||||
}
|
||||
|
||||
logger.info(f"✅ Generated plugin.json with {len(commands)} commands")
|
||||
|
||||
return plugin_json
|
||||
|
||||
def write(self, plugin_json: dict, dry_run: bool = False):
|
||||
"""Write plugin.json to .claude-plugin/ directory."""
|
||||
output_path = self.plugin_root / '.claude-plugin' / 'plugin.json'
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"[DRY RUN] Would write plugin.json to: {output_path}")
|
||||
logger.info(json.dumps(plugin_json, indent=2))
|
||||
return
|
||||
|
||||
output_path.write_text(
|
||||
json.dumps(plugin_json, indent=2) + '\n',
|
||||
encoding='utf-8'
|
||||
)
|
||||
logger.info(f"✅ Written: {output_path}")
|
||||
|
||||
|
||||
class McpMerger:
|
||||
"""Safely merges MCP server configurations."""
|
||||
|
||||
def __init__(self, plugin_root: Path):
|
||||
self.plugin_root = plugin_root
|
||||
|
||||
def merge(
|
||||
self,
|
||||
framework_mcp: dict,
|
||||
plugin_mcp: dict
|
||||
) -> Tuple[dict, List[str]]:
|
||||
"""
|
||||
Merge MCP configurations with conflict detection.
|
||||
|
||||
Strategy:
|
||||
- Framework servers take precedence
|
||||
- Preserve Plugin-specific servers
|
||||
- Log warnings for conflicts
|
||||
|
||||
Args:
|
||||
framework_mcp: MCP servers from Framework
|
||||
plugin_mcp: MCP servers from Plugin
|
||||
|
||||
Returns:
|
||||
(merged_config, warnings)
|
||||
"""
|
||||
merged = {}
|
||||
warnings = []
|
||||
|
||||
# Add Framework servers (source of truth)
|
||||
for name, config in framework_mcp.items():
|
||||
merged[name] = config
|
||||
|
||||
# Add Plugin-specific servers if not in Framework
|
||||
for name, config in plugin_mcp.items():
|
||||
if name not in merged:
|
||||
merged[name] = config
|
||||
warnings.append(
|
||||
f"Preserved plugin-specific MCP server: {name}"
|
||||
)
|
||||
else:
|
||||
# Check if configurations differ
|
||||
if config != merged[name]:
|
||||
warnings.append(
|
||||
f"MCP server '{name}' conflict - using Framework version"
|
||||
)
|
||||
|
||||
return merged, warnings
|
||||
|
||||
def backup_current(self) -> Optional[Path]:
|
||||
"""Create backup of current plugin.json."""
|
||||
plugin_json = self.plugin_root / 'plugin.json'
|
||||
if not plugin_json.exists():
|
||||
return None
|
||||
|
||||
backup_dir = self.plugin_root / 'backups'
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = backup_dir / f'plugin.json.{timestamp}.backup'
|
||||
|
||||
shutil.copy2(plugin_json, backup_path)
|
||||
logger.info(f"📦 Backup created: {backup_path}")
|
||||
|
||||
return backup_path
|
||||
|
||||
|
||||
class FrameworkSyncer:
|
||||
"""Main orchestrator for Framework → Plugin sync."""
|
||||
|
||||
# ── SYNC MAPPINGS ──────────────────────────────────────────────────────────
|
||||
# What to pull from Framework and transform for Plugin distribution.
|
||||
# Symmetric pair with PROTECTED_PATHS below: a path appears in one or the other,
|
||||
# never both.
|
||||
SYNC_MAPPINGS = {
|
||||
"src/superclaude/commands": "commands", # /cmd → /sc:cmd, sc- prefix
|
||||
"src/superclaude/agents": "agents", # name → sc-name in frontmatter
|
||||
# core/ and modes/ are intentionally absent — they live in PROTECTED_PATHS
|
||||
}
|
||||
|
||||
# ── PROTECTED PATHS ────────────────────────────────────────────────────────
|
||||
# Plugin-owned files and directories that must NEVER be overwritten by sync,
|
||||
# regardless of what the Framework contains.
|
||||
#
|
||||
# Algorithm: before sync → hash all protected paths → after sync → re-hash
|
||||
# and raise ProtectionViolationError if anything changed.
|
||||
#
|
||||
# To move a path from protected to synced: remove it here, add to SYNC_MAPPINGS.
|
||||
PROTECTED_PATHS: List[str] = [
|
||||
# Plugin-specific documentation (Plugin spec, not Framework spec)
|
||||
"README.md",
|
||||
"README-ja.md",
|
||||
"README-zh.md",
|
||||
"BACKUP_GUIDE.md",
|
||||
"MIGRATION_GUIDE.md",
|
||||
"SECURITY.md",
|
||||
"CLAUDE.md",
|
||||
"LICENSE",
|
||||
".gitignore",
|
||||
# Plugin configuration & marketplace metadata
|
||||
".claude-plugin/",
|
||||
# Plugin infrastructure (workflows, scripts, tests are Plugin-owned)
|
||||
".github/",
|
||||
"docs/",
|
||||
"scripts/",
|
||||
"tests/",
|
||||
"backups/",
|
||||
# Plugin-customized behavioral content
|
||||
# Plugin maintains its own tuned versions; Framework versions are ignored.
|
||||
"core/",
|
||||
"modes/",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
framework_repo: str,
|
||||
plugin_root: Path,
|
||||
dry_run: bool = False
|
||||
):
|
||||
self.framework_repo = framework_repo
|
||||
self.plugin_root = plugin_root
|
||||
self.dry_run = dry_run
|
||||
self.temp_dir = None
|
||||
self.warnings = []
|
||||
self.errors = []
|
||||
|
||||
def sync(self) -> SyncResult:
|
||||
"""Execute full sync workflow."""
|
||||
try:
|
||||
logger.info("🔄 Starting Framework sync...")
|
||||
|
||||
# Step 1: Clone Framework
|
||||
framework_path = self._clone_framework()
|
||||
framework_commit = self._get_commit_hash(framework_path)
|
||||
framework_version = self._get_version(framework_path)
|
||||
|
||||
logger.info(f"📦 Framework version: {framework_version}")
|
||||
logger.info(f"📝 Framework commit: {framework_commit[:8]}")
|
||||
|
||||
# Step 2: Snapshot protected files BEFORE any changes
|
||||
protection_snapshot = self._snapshot_protected_files()
|
||||
|
||||
# Step 3: Create backup
|
||||
self._create_backup()
|
||||
|
||||
# Step 4: Transform and sync content
|
||||
stats = self._sync_content(framework_path)
|
||||
|
||||
# Step 5: Verify protected files were NOT touched
|
||||
self._validate_protected_files(protection_snapshot)
|
||||
|
||||
# Step 6: Generate plugin.json
|
||||
self._generate_plugin_json(framework_version)
|
||||
|
||||
# Step 7: Merge MCP configurations
|
||||
mcp_merged = self._merge_mcp_configs(framework_path)
|
||||
|
||||
# Step 8: Validate sync results
|
||||
self._validate_sync()
|
||||
|
||||
logger.info("✅ Sync completed successfully!")
|
||||
|
||||
return SyncResult(
|
||||
success=True,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
framework_commit=framework_commit,
|
||||
framework_version=framework_version,
|
||||
files_synced=stats['files_synced'],
|
||||
files_modified=stats['files_modified'],
|
||||
commands_transformed=stats['commands'],
|
||||
agents_transformed=stats['agents'],
|
||||
mcp_servers_merged=mcp_merged,
|
||||
warnings=self.warnings,
|
||||
errors=self.errors
|
||||
)
|
||||
|
||||
except ProtectionViolationError as e:
|
||||
# Protection violations are logged already; surface them clearly in the report
|
||||
self.errors.append(str(e))
|
||||
return SyncResult(
|
||||
success=False,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
framework_commit="",
|
||||
framework_version="",
|
||||
files_synced=0,
|
||||
files_modified=0,
|
||||
commands_transformed=0,
|
||||
agents_transformed=0,
|
||||
mcp_servers_merged=0,
|
||||
warnings=self.warnings,
|
||||
errors=self.errors
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Sync failed: {e}", exc_info=True)
|
||||
self.errors.append(str(e))
|
||||
return SyncResult(
|
||||
success=False,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
framework_commit="",
|
||||
framework_version="",
|
||||
files_synced=0,
|
||||
files_modified=0,
|
||||
commands_transformed=0,
|
||||
agents_transformed=0,
|
||||
mcp_servers_merged=0,
|
||||
warnings=self.warnings,
|
||||
errors=self.errors
|
||||
)
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
# ── Protection helpers ─────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Return SHA-256 hex digest of a file's contents."""
|
||||
h = hashlib.sha256()
|
||||
h.update(path.read_bytes())
|
||||
return h.hexdigest()
|
||||
|
||||
def _snapshot_protected_files(self) -> Dict[str, str]:
|
||||
"""
|
||||
Hash every file that lives under a PROTECTED_PATHS entry.
|
||||
|
||||
Called BEFORE sync begins so we have a baseline to compare against.
|
||||
|
||||
Returns:
|
||||
Mapping of relative-path-string → SHA-256 hex digest.
|
||||
"""
|
||||
snapshot: Dict[str, str] = {}
|
||||
for protected in self.PROTECTED_PATHS:
|
||||
target = self.plugin_root / protected
|
||||
if target.is_file():
|
||||
rel = protected
|
||||
snapshot[rel] = self._hash_file(target)
|
||||
elif target.is_dir():
|
||||
for f in sorted(target.rglob('*')):
|
||||
if f.is_file():
|
||||
rel = str(f.relative_to(self.plugin_root))
|
||||
snapshot[rel] = self._hash_file(f)
|
||||
logger.info(f"🔒 Protection snapshot: {len(snapshot)} Plugin-owned files hashed")
|
||||
return snapshot
|
||||
|
||||
def _validate_protected_files(self, snapshot: Dict[str, str]) -> None:
|
||||
"""
|
||||
Re-hash every file from the snapshot and compare.
|
||||
|
||||
Called AFTER sync to verify no protected file was touched.
|
||||
|
||||
Raises:
|
||||
ProtectionViolationError: if any protected file was modified or deleted.
|
||||
"""
|
||||
violations: List[str] = []
|
||||
for rel_path, original_hash in snapshot.items():
|
||||
current = self.plugin_root / rel_path
|
||||
if not current.exists():
|
||||
violations.append(f"DELETED : {rel_path}")
|
||||
else:
|
||||
current_hash = self._hash_file(current)
|
||||
if current_hash != original_hash:
|
||||
violations.append(f"MODIFIED : {rel_path}")
|
||||
|
||||
if violations:
|
||||
msg = (
|
||||
"🚨 PROTECTION VIOLATION — sync modified Plugin-owned files:\n"
|
||||
+ "\n".join(f" • {v}" for v in violations)
|
||||
+ "\n\nFix: ensure SYNC_MAPPINGS does not target any path in PROTECTED_PATHS."
|
||||
)
|
||||
logger.error(msg)
|
||||
raise ProtectionViolationError(msg)
|
||||
|
||||
logger.info(f"🔒 Protection check passed — {len(snapshot)} Plugin-owned files unchanged")
|
||||
|
||||
# ── Core sync workflow ─────────────────────────────────────────────────────
|
||||
|
||||
def _clone_framework(self) -> Path:
|
||||
"""Clone Framework repository to temp directory."""
|
||||
logger.info(f"📥 Cloning Framework: {self.framework_repo}")
|
||||
|
||||
self.temp_dir = tempfile.mkdtemp(prefix='superclaude_framework_')
|
||||
framework_path = Path(self.temp_dir) / 'framework'
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
['git', 'clone', '--depth', '1', self.framework_repo, str(framework_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
logger.info(f"✅ Cloned to: {framework_path}")
|
||||
return framework_path
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Failed to clone Framework: {e.stderr}")
|
||||
raise
|
||||
|
||||
def _get_commit_hash(self, repo_path: Path) -> str:
|
||||
"""Get current commit hash from repository."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', 'HEAD'],
|
||||
cwd=repo_path,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
def _get_version(self, framework_path: Path) -> str:
|
||||
"""Extract version from Framework."""
|
||||
# Try to read version from plugin.json or package.json
|
||||
for version_file in ['plugin.json', 'package.json']:
|
||||
version_path = framework_path / version_file
|
||||
if version_path.exists():
|
||||
try:
|
||||
data = json.loads(version_path.read_text())
|
||||
if 'version' in data:
|
||||
return data['version']
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
# Fallback to current Plugin version
|
||||
plugin_json = self.plugin_root / 'plugin.json'
|
||||
if plugin_json.exists():
|
||||
try:
|
||||
data = json.loads(plugin_json.read_text())
|
||||
return data.get('version', '1.0.0')
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return '1.0.0'
|
||||
|
||||
def _create_backup(self):
|
||||
"""Create backup of current plugin state."""
|
||||
logger.info("📦 Creating backup...")
|
||||
|
||||
mcp_merger = McpMerger(self.plugin_root)
|
||||
backup_path = mcp_merger.backup_current()
|
||||
|
||||
if backup_path:
|
||||
logger.info(f"✅ Backup created: {backup_path}")
|
||||
|
||||
def _sync_content(self, framework_path: Path) -> Dict[str, int]:
|
||||
"""Sync and transform content from Framework."""
|
||||
logger.info("🔄 Syncing content...")
|
||||
|
||||
file_syncer = FileSyncer(self.plugin_root, self.dry_run)
|
||||
stats = {
|
||||
'files_synced': 0,
|
||||
'files_modified': 0,
|
||||
'commands': 0,
|
||||
'agents': 0
|
||||
}
|
||||
|
||||
# Sync commands with transformation
|
||||
logger.info("📝 Syncing commands...")
|
||||
source_commands = framework_path / 'src/superclaude/commands'
|
||||
dest_commands = self.plugin_root / 'commands'
|
||||
|
||||
if source_commands.exists():
|
||||
cmd_stats = file_syncer.sync_directory(
|
||||
source_commands,
|
||||
dest_commands,
|
||||
filename_prefix='sc-',
|
||||
transform_fn=ContentTransformer.transform_command
|
||||
)
|
||||
stats['commands'] = cmd_stats['synced'] + cmd_stats['modified']
|
||||
stats['files_synced'] += cmd_stats['synced']
|
||||
stats['files_modified'] += cmd_stats['modified']
|
||||
logger.info(f"✅ Commands: {stats['commands']} transformed")
|
||||
|
||||
# Sync agents with transformation
|
||||
logger.info("📝 Syncing agents...")
|
||||
source_agents = framework_path / 'src/superclaude/agents'
|
||||
dest_agents = self.plugin_root / 'agents'
|
||||
|
||||
if source_agents.exists():
|
||||
agent_stats = file_syncer.sync_directory(
|
||||
source_agents,
|
||||
dest_agents,
|
||||
filename_prefix='sc-',
|
||||
transform_fn=ContentTransformer.transform_agent
|
||||
)
|
||||
stats['agents'] = agent_stats['synced'] + agent_stats['modified']
|
||||
stats['files_synced'] += agent_stats['synced']
|
||||
stats['files_modified'] += agent_stats['modified']
|
||||
logger.info(f"✅ Agents: {stats['agents']} transformed")
|
||||
|
||||
# core/ and modes/ are in PROTECTED_PATHS — Plugin maintains its own versions.
|
||||
# They are intentionally excluded from SYNC_MAPPINGS and will never be
|
||||
# overwritten here. To re-enable Framework sync for either directory,
|
||||
# remove it from PROTECTED_PATHS and add it back to SYNC_MAPPINGS.
|
||||
logger.info("🔒 core/ and modes/ are Plugin-owned (PROTECTED_PATHS) — skipping")
|
||||
|
||||
return stats
|
||||
|
||||
def _generate_plugin_json(self, framework_version: str):
|
||||
"""Generate plugin.json from synced commands."""
|
||||
logger.info("📄 Generating plugin.json...")
|
||||
|
||||
generator = PluginJsonGenerator(self.plugin_root)
|
||||
plugin_json = generator.generate(framework_version)
|
||||
generator.write(plugin_json, self.dry_run)
|
||||
|
||||
def _merge_mcp_configs(self, framework_path: Path) -> int:
|
||||
"""Merge MCP configurations from Framework."""
|
||||
logger.info("🔗 Merging MCP configurations...")
|
||||
|
||||
# Read Framework MCP config
|
||||
framework_plugin_json = framework_path / 'plugin.json'
|
||||
framework_mcp = {}
|
||||
|
||||
if framework_plugin_json.exists():
|
||||
try:
|
||||
data = json.loads(framework_plugin_json.read_text())
|
||||
framework_mcp = data.get('mcpServers', {})
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to read Framework plugin.json")
|
||||
|
||||
# Read Plugin MCP config
|
||||
plugin_json_path = self.plugin_root / 'plugin.json'
|
||||
plugin_mcp = {}
|
||||
|
||||
if plugin_json_path.exists():
|
||||
try:
|
||||
data = json.loads(plugin_json_path.read_text())
|
||||
plugin_mcp = data.get('mcpServers', {})
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to read Plugin plugin.json")
|
||||
|
||||
# Merge configurations
|
||||
merger = McpMerger(self.plugin_root)
|
||||
merged_mcp, warnings = merger.merge(framework_mcp, plugin_mcp)
|
||||
|
||||
# Log warnings
|
||||
for warning in warnings:
|
||||
logger.warning(f"⚠️ {warning}")
|
||||
self.warnings.append(warning)
|
||||
|
||||
# Update plugin.json with merged MCP config
|
||||
if not self.dry_run and plugin_json_path.exists():
|
||||
data = json.loads(plugin_json_path.read_text())
|
||||
data['mcpServers'] = merged_mcp
|
||||
plugin_json_path.write_text(
|
||||
json.dumps(data, indent=2) + '\n',
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
logger.info(f"✅ MCP servers merged: {len(merged_mcp)}")
|
||||
return len(merged_mcp)
|
||||
|
||||
def _validate_sync(self):
|
||||
"""Validate sync results."""
|
||||
logger.info("🔍 Validating sync...")
|
||||
|
||||
# Check commands directory
|
||||
commands_dir = self.plugin_root / 'commands'
|
||||
if commands_dir.exists():
|
||||
sc_commands = list(commands_dir.glob('sc-*.md'))
|
||||
logger.info(f"✅ Found {len(sc_commands)} sc- prefixed commands")
|
||||
|
||||
# Check agents directory
|
||||
agents_dir = self.plugin_root / 'agents'
|
||||
if agents_dir.exists():
|
||||
sc_agents = list(agents_dir.glob('sc-*.md'))
|
||||
logger.info(f"✅ Found {len(sc_agents)} sc- prefixed agents")
|
||||
|
||||
# Check plugin.json
|
||||
plugin_json_path = self.plugin_root / '.claude-plugin' / 'plugin.json'
|
||||
if plugin_json_path.exists():
|
||||
logger.info(f"✅ plugin.json exists at {plugin_json_path}")
|
||||
else:
|
||||
logger.warning("⚠️ plugin.json not found")
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up temporary directories."""
|
||||
if self.temp_dir and Path(self.temp_dir).exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
logger.debug(f"🧹 Cleaned up temp directory: {self.temp_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Sync SuperClaude Framework to Plugin with namespace isolation'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--framework-repo',
|
||||
default='https://github.com/SuperClaude-Org/SuperClaude_Framework',
|
||||
help='Framework repository URL'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--plugin-root',
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help='Plugin repository root path'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||
default=False,
|
||||
help='Preview changes without applying'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output-report',
|
||||
type=Path,
|
||||
help='Save sync report to file'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose logging'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
if args.dry_run:
|
||||
logger.info("🔍 DRY RUN MODE - No changes will be applied")
|
||||
|
||||
# Run sync
|
||||
syncer = FrameworkSyncer(
|
||||
framework_repo=args.framework_repo,
|
||||
plugin_root=args.plugin_root,
|
||||
dry_run=args.dry_run
|
||||
)
|
||||
|
||||
result = syncer.sync()
|
||||
|
||||
# Output report
|
||||
if args.output_report:
|
||||
args.output_report.write_text(
|
||||
json.dumps(result.to_dict(), indent=2) + '\n'
|
||||
)
|
||||
logger.info(f"📊 Report saved to: {args.output_report}")
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 60)
|
||||
print("SYNC SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"Success: {result.success}")
|
||||
print(f"Framework Version: {result.framework_version}")
|
||||
print(f"Framework Commit: {result.framework_commit[:8]}")
|
||||
print(f"Files Synced: {result.files_synced}")
|
||||
print(f"Files Modified: {result.files_modified}")
|
||||
print(f"Commands Transformed: {result.commands_transformed}")
|
||||
print(f"Agents Transformed: {result.agents_transformed}")
|
||||
print(f"MCP Servers Merged: {result.mcp_servers_merged}")
|
||||
|
||||
if result.warnings:
|
||||
print(f"\n⚠️ Warnings: {len(result.warnings)}")
|
||||
for warning in result.warnings:
|
||||
print(f" - {warning}")
|
||||
|
||||
if result.errors:
|
||||
print(f"\n❌ Errors: {len(result.errors)}")
|
||||
for error in result.errors:
|
||||
print(f" - {error}")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
sys.exit(0 if result.success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# SuperClaude Legacy Cleanup Script
|
||||
# Removes old SuperClaude-related files from ~/.claude
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CLAUDE_DIR="$HOME/.claude"
|
||||
BACKUP_DIR="$HOME/.claude-superclaude-backup-$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
echo "🧹 SuperClaude Legacy Cleanup"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
|
||||
# Check if .claude directory exists
|
||||
if [[ ! -d "$CLAUDE_DIR" ]]; then
|
||||
echo "✅ No .claude directory found - nothing to clean"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# List of SuperClaude-related files/directories to remove
|
||||
SUPERCLAUDE_ITEMS=(
|
||||
# SuperClaude plugin (if exists)
|
||||
"plugins/superclaude@superclaude"
|
||||
|
||||
# Legacy SuperClaude configs (if any)
|
||||
"superclaude.json"
|
||||
"superclaude_config.json"
|
||||
)
|
||||
|
||||
# Function to backup and remove
|
||||
backup_and_remove() {
|
||||
local item="$1"
|
||||
local full_path="$CLAUDE_DIR/$item"
|
||||
|
||||
if [[ -e "$full_path" ]] || [[ -L "$full_path" ]]; then
|
||||
echo "📦 Backing up: $item"
|
||||
mkdir -p "$BACKUP_DIR/$(dirname "$item")"
|
||||
cp -R "$full_path" "$BACKUP_DIR/$item" 2>/dev/null || true
|
||||
|
||||
echo "🗑️ Removing: $item"
|
||||
rm -rf "$full_path"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "🔍 Scanning for SuperClaude files..."
|
||||
echo ""
|
||||
|
||||
FOUND_COUNT=0
|
||||
for item in "${SUPERCLAUDE_ITEMS[@]}"; do
|
||||
if backup_and_remove "$item"; then
|
||||
((FOUND_COUNT++))
|
||||
fi
|
||||
done
|
||||
|
||||
# Clean up settings.json if it contains SuperClaude plugin reference
|
||||
SETTINGS_FILE="$CLAUDE_DIR/settings.json"
|
||||
if [[ -f "$SETTINGS_FILE" ]]; then
|
||||
if grep -q "superclaude@superclaude" "$SETTINGS_FILE" 2>/dev/null; then
|
||||
echo "📦 Backing up: settings.json"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp "$SETTINGS_FILE" "$BACKUP_DIR/settings.json"
|
||||
|
||||
echo "🧹 Removing SuperClaude plugin from settings.json"
|
||||
# Use Python to properly manipulate JSON
|
||||
python3 - <<'PYEOF' "$SETTINGS_FILE"
|
||||
import json
|
||||
import sys
|
||||
|
||||
settings_file = sys.argv[1]
|
||||
|
||||
try:
|
||||
with open(settings_file, 'r') as f:
|
||||
settings = json.load(f)
|
||||
|
||||
# Remove SuperClaude plugin
|
||||
if 'enabledPlugins' in settings:
|
||||
if 'superclaude@superclaude' in settings['enabledPlugins']:
|
||||
del settings['enabledPlugins']['superclaude@superclaude']
|
||||
print(f" ✅ Removed superclaude@superclaude from enabledPlugins")
|
||||
|
||||
# Remove enabledPlugins if empty
|
||||
if not settings['enabledPlugins']:
|
||||
del settings['enabledPlugins']
|
||||
|
||||
with open(settings_file, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
f.write('\n')
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed to update settings.json: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
((FOUND_COUNT++))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=============================="
|
||||
|
||||
if [[ $FOUND_COUNT -eq 0 ]]; then
|
||||
echo "✅ No SuperClaude files found - already clean!"
|
||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||
else
|
||||
echo "🎉 Cleanup complete!"
|
||||
echo ""
|
||||
echo "📦 Backup saved to:"
|
||||
echo " $BACKUP_DIR"
|
||||
echo ""
|
||||
echo "Removed $FOUND_COUNT SuperClaude-related item(s)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "ℹ️ Note: Official Claude Code files were NOT touched"
|
||||
echo " (history.jsonl, mcp.json, projects/, etc.)"
|
||||
Reference in New Issue
Block a user