267 lines
8.0 KiB
Python
267 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Navigator Version Detector
|
|
|
|
Detects current Navigator version and checks for updates from GitHub releases.
|
|
|
|
Usage:
|
|
python version_detector.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Optional
|
|
from urllib import request
|
|
|
|
|
|
def get_current_version() -> Optional[str]:
|
|
"""
|
|
Get currently installed Navigator version from /plugin list.
|
|
|
|
Returns:
|
|
Version string (e.g., "3.3.0") or None if not found
|
|
"""
|
|
try:
|
|
# Try to run claude plugin list command
|
|
result = subprocess.run(
|
|
['claude', 'plugin', 'list'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
# Parse output for navigator version
|
|
for line in result.stdout.split('\n'):
|
|
if 'navigator' in line.lower():
|
|
# Extract version (e.g., "navigator (v3.3.0)" or "navigator (3.3.0)")
|
|
match = re.search(r'v?(\d+\.\d+\.\d+)', line)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
return None
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
|
return None
|
|
|
|
|
|
def get_plugin_json_version() -> Optional[str]:
|
|
"""
|
|
Fallback: Get version from plugin.json in Navigator plugin directory.
|
|
|
|
Returns:
|
|
Version string or None
|
|
"""
|
|
# Common plugin installation paths
|
|
possible_paths = [
|
|
Path.home() / '.config' / 'claude' / 'plugins' / 'navigator' / '.claude-plugin' / 'plugin.json',
|
|
Path.home() / '.claude' / 'plugins' / 'navigator' / '.claude-plugin' / 'plugin.json',
|
|
Path.home() / 'Library' / 'Application Support' / 'Claude' / 'plugins' / 'navigator' / '.claude-plugin' / 'plugin.json',
|
|
]
|
|
|
|
for path in possible_paths:
|
|
if path.exists():
|
|
try:
|
|
with open(path, 'r') as f:
|
|
data = json.load(f)
|
|
return data.get('version')
|
|
except (json.JSONDecodeError, FileNotFoundError, PermissionError):
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def get_latest_version_from_github() -> Dict:
|
|
"""
|
|
Get latest Navigator version from GitHub releases API.
|
|
|
|
Returns:
|
|
Dict with version, release_url, and changes
|
|
"""
|
|
try:
|
|
url = 'https://api.github.com/repos/alekspetrov/navigator/releases/latest'
|
|
|
|
req = request.Request(url)
|
|
req.add_header('User-Agent', 'Navigator-Version-Detector')
|
|
|
|
with request.urlopen(req, timeout=10) as response:
|
|
data = json.load(response)
|
|
|
|
# Extract version from tag_name (e.g., "v3.3.0" → "3.3.0")
|
|
tag_name = data.get('tag_name', '')
|
|
version = tag_name.lstrip('v')
|
|
|
|
# Parse release notes for key changes
|
|
body = data.get('body', '')
|
|
changes = parse_release_notes(body)
|
|
|
|
return {
|
|
'version': version,
|
|
'release_url': data.get('html_url', ''),
|
|
'release_date': data.get('published_at', '').split('T')[0],
|
|
'changes': changes
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
'version': None,
|
|
'error': str(e)
|
|
}
|
|
|
|
|
|
def parse_release_notes(body: str) -> Dict:
|
|
"""
|
|
Parse release notes to extract key changes.
|
|
|
|
Args:
|
|
body: Release notes markdown
|
|
|
|
Returns:
|
|
Dict with new_skills, updated_skills, new_features, breaking_changes
|
|
"""
|
|
changes = {
|
|
'new_skills': [],
|
|
'updated_skills': [],
|
|
'new_features': [],
|
|
'breaking_changes': []
|
|
}
|
|
|
|
# Extract new skills
|
|
skill_pattern = r'-\s+\*\*(\w+-[\w-]+)\*\*:.*\(NEW\)'
|
|
for match in re.finditer(skill_pattern, body):
|
|
changes['new_skills'].append(match.group(1))
|
|
|
|
# Extract features from "What's New" section
|
|
features_section = re.search(r'##\s+.*What.*s New(.*?)(?=##|\Z)', body, re.DOTALL | re.IGNORECASE)
|
|
if features_section:
|
|
# Find bullet points
|
|
for line in features_section.group(1).split('\n'):
|
|
if line.strip().startswith('-') or line.strip().startswith('*'):
|
|
feature = line.strip().lstrip('-*').strip()
|
|
if feature and len(feature) < 100: # Reasonable feature description
|
|
changes['new_features'].append(feature)
|
|
|
|
# Check for breaking changes
|
|
if 'breaking change' in body.lower() or '⚠️' in body:
|
|
breaking_section = re.search(r'##\s+.*Breaking.*Changes(.*?)(?=##|\Z)', body, re.DOTALL | re.IGNORECASE)
|
|
if breaking_section:
|
|
for line in breaking_section.group(1).split('\n'):
|
|
if line.strip().startswith('-') or line.strip().startswith('*'):
|
|
change = line.strip().lstrip('-*').strip()
|
|
if change:
|
|
changes['breaking_changes'].append(change)
|
|
|
|
return changes
|
|
|
|
|
|
def compare_versions(current: str, latest: str) -> int:
|
|
"""
|
|
Compare two semantic versions.
|
|
|
|
Args:
|
|
current: Current version (e.g., "3.2.0")
|
|
latest: Latest version (e.g., "3.3.0")
|
|
|
|
Returns:
|
|
-1 if current < latest (update available)
|
|
0 if current == latest (up to date)
|
|
1 if current > latest (ahead of latest, e.g., dev version)
|
|
"""
|
|
try:
|
|
current_parts = [int(x) for x in current.split('.')]
|
|
latest_parts = [int(x) for x in latest.split('.')]
|
|
|
|
# Pad to same length
|
|
while len(current_parts) < len(latest_parts):
|
|
current_parts.append(0)
|
|
while len(latest_parts) < len(current_parts):
|
|
latest_parts.append(0)
|
|
|
|
# Compare
|
|
for c, l in zip(current_parts, latest_parts):
|
|
if c < l:
|
|
return -1
|
|
elif c > l:
|
|
return 1
|
|
|
|
return 0
|
|
except (ValueError, AttributeError):
|
|
return 0 # Can't compare, assume equal
|
|
|
|
|
|
def detect_version() -> Dict:
|
|
"""
|
|
Detect current and latest Navigator versions.
|
|
|
|
Returns:
|
|
Complete version detection report
|
|
"""
|
|
# Get current version
|
|
current_version = get_current_version()
|
|
|
|
if not current_version:
|
|
# Fallback to plugin.json
|
|
current_version = get_plugin_json_version()
|
|
|
|
# Get latest version from GitHub
|
|
latest_info = get_latest_version_from_github()
|
|
latest_version = latest_info.get('version')
|
|
|
|
# Determine if update available
|
|
update_available = False
|
|
if current_version and latest_version:
|
|
comparison = compare_versions(current_version, latest_version)
|
|
update_available = (comparison == -1)
|
|
|
|
# Build report
|
|
report = {
|
|
'current_version': current_version,
|
|
'latest_version': latest_version,
|
|
'update_available': update_available,
|
|
'release_url': latest_info.get('release_url', ''),
|
|
'release_date': latest_info.get('release_date', ''),
|
|
'changes': latest_info.get('changes', {}),
|
|
'error': latest_info.get('error'),
|
|
'recommendation': get_recommendation(current_version, latest_version, update_available)
|
|
}
|
|
|
|
return report
|
|
|
|
|
|
def get_recommendation(current: Optional[str], latest: Optional[str], update_available: bool) -> str:
|
|
"""Generate recommendation based on version status."""
|
|
if not current:
|
|
return "Navigator not detected. Install: /plugin marketplace add alekspetrov/navigator && /plugin install navigator"
|
|
|
|
if not latest:
|
|
return "Could not check for updates. Try again later or check GitHub releases manually."
|
|
|
|
if update_available:
|
|
return f"Update recommended: v{current} → v{latest}. Run: /plugin update navigator"
|
|
|
|
return f"You're on the latest version (v{current}). No update needed."
|
|
|
|
|
|
def main():
|
|
"""CLI entry point."""
|
|
report = detect_version()
|
|
|
|
# Output as JSON
|
|
print(json.dumps(report, indent=2))
|
|
|
|
# Exit with code
|
|
# 0 = up to date
|
|
# 1 = update available
|
|
# 2 = error
|
|
if report.get('error'):
|
|
sys.exit(2)
|
|
elif report.get('update_available'):
|
|
sys.exit(1)
|
|
else:
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|