diff --git a/SuperClaude/__main__.py b/SuperClaude/__main__.py index dfc787e..8363691 100644 --- a/SuperClaude/__main__.py +++ b/SuperClaude/__main__.py @@ -73,6 +73,10 @@ def create_global_parser() -> argparse.ArgumentParser: help="Force execution, skipping checks") global_parser.add_argument("--yes", "-y", action="store_true", help="Automatically answer yes to all prompts") + global_parser.add_argument("--no-update-check", action="store_true", + help="Skip checking for updates") + global_parser.add_argument("--auto-update", action="store_true", + help="Automatically install updates without prompting") return global_parser @@ -198,6 +202,26 @@ def main() -> int: parser, subparsers, global_parser = create_parser() operations = register_operation_parsers(subparsers, global_parser) args = parser.parse_args() + + # Check for updates unless disabled + if not args.quiet and not getattr(args, 'no_update_check', False): + try: + from setup.utils.updater import check_for_updates + # Check for updates in the background + updated = check_for_updates( + current_version="4.0.6", + auto_update=getattr(args, 'auto_update', False) + ) + # If updated, suggest restart + if updated: + print("\n🔄 SuperClaude was updated. Please restart to use the new version.") + return 0 + except ImportError: + # Updater module not available, skip silently + pass + except Exception: + # Any other error, skip silently + pass # No operation provided? Show help manually unless in quiet mode if not args.operation: diff --git a/bin/checkUpdate.js b/bin/checkUpdate.js new file mode 100644 index 0000000..f9c4b6d --- /dev/null +++ b/bin/checkUpdate.js @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** + * Auto-update checker for SuperClaude NPM package + * Checks npm registry for newer versions and offers automatic updates + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const https = require('https'); + +const CACHE_FILE = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', '.npm_update_check'); +const CHECK_INTERVAL = 86400000; // 24 hours in milliseconds +const TIMEOUT = 2000; // 2 seconds +const PACKAGE_NAME = '@bifrost_inc/superclaude'; + +/** + * Get the current package version from package.json + */ +function getCurrentVersion() { + try { + const packagePath = path.join(__dirname, '..', 'package.json'); + const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + return packageData.version; + } catch (error) { + return null; + } +} + +/** + * Check if we should perform an update check based on last check time + */ +function shouldCheckUpdate(force = false) { + if (force) return true; + + try { + if (!fs.existsSync(CACHE_FILE)) return true; + + const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + const lastCheck = data.lastCheck || 0; + + // Check if 24 hours have passed + return Date.now() - lastCheck > CHECK_INTERVAL; + } catch { + return true; + } +} + +/** + * Save the current timestamp as last check time + */ +function saveCheckTimestamp() { + const cacheDir = path.dirname(CACHE_FILE); + + // Create directory if it doesn't exist + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + let data = {}; + try { + if (fs.existsSync(CACHE_FILE)) { + data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + } + } catch { + // Ignore errors + } + + data.lastCheck = Date.now(); + fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); +} + +/** + * Query npm registry for the latest version + */ +function getLatestVersion() { + return new Promise((resolve) => { + const options = { + hostname: 'registry.npmjs.org', + path: `/${PACKAGE_NAME}/latest`, + method: 'GET', + timeout: TIMEOUT, + headers: { + 'User-Agent': 'SuperClaude-Updater' + } + }; + + const req = https.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const packageData = JSON.parse(data); + resolve(packageData.version); + } catch { + resolve(null); + } + }); + }); + + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + + req.setTimeout(TIMEOUT); + req.end(); + }); +} + +/** + * Compare version strings + */ +function isNewerVersion(current, latest) { + if (!current || !latest) return false; + + const currentParts = current.split('.').map(Number); + const latestParts = latest.split('.').map(Number); + + for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { + const currentPart = currentParts[i] || 0; + const latestPart = latestParts[i] || 0; + + if (latestPart > currentPart) return true; + if (latestPart < currentPart) return false; + } + + return false; +} + +/** + * Detect if npm or yarn is being used globally + */ +function detectPackageManager() { + // Check if installed globally with npm + const npmResult = spawnSync('npm', ['list', '-g', PACKAGE_NAME], { + encoding: 'utf8', + shell: true + }); + + if (npmResult.status === 0 && npmResult.stdout.includes(PACKAGE_NAME)) { + return 'npm'; + } + + // Check if installed globally with yarn + const yarnResult = spawnSync('yarn', ['global', 'list'], { + encoding: 'utf8', + shell: true + }); + + if (yarnResult.status === 0 && yarnResult.stdout.includes(PACKAGE_NAME)) { + return 'yarn'; + } + + return 'npm'; // Default to npm +} + +/** + * Get the appropriate update command + */ +function getUpdateCommand() { + const pm = detectPackageManager(); + + if (pm === 'yarn') { + return `yarn global upgrade ${PACKAGE_NAME}`; + } + + return `npm update -g ${PACKAGE_NAME}`; +} + +/** + * Show update banner + */ +function showUpdateBanner(currentVersion, latestVersion, autoUpdate = false) { + const updateCmd = getUpdateCommand(); + + console.log('\n\x1b[36m╔════════════════════════════════════════════════╗\x1b[0m'); + console.log(`\x1b[36m║\x1b[33m 🚀 Update Available: ${currentVersion} → ${latestVersion} \x1b[36m║\x1b[0m`); + console.log(`\x1b[36m║\x1b[32m Run: ${updateCmd.padEnd(30)} \x1b[36m║\x1b[0m`); + console.log('\x1b[36m╚════════════════════════════════════════════════╝\x1b[0m\n'); + + return autoUpdate || process.env.SUPERCLAUDE_AUTO_UPDATE === 'true'; +} + +/** + * Perform the update + */ +function performUpdate() { + const updateCmd = getUpdateCommand(); + console.log('\x1b[36m🔄 Updating SuperClaude...\x1b[0m'); + + const cmdParts = updateCmd.split(' '); + const result = spawnSync(cmdParts[0], cmdParts.slice(1), { + stdio: 'inherit', + shell: true + }); + + if (result.status === 0) { + console.log('\x1b[32m✅ Update completed successfully!\x1b[0m'); + console.log('\x1b[33mPlease restart SuperClaude to use the new version.\x1b[0m'); + return true; + } else { + console.log('\x1b[33m⚠️ Update failed. Please run manually:\x1b[0m'); + console.log(` ${updateCmd}`); + return false; + } +} + +/** + * Main function to check and notify for updates + */ +async function checkAndNotify(options = {}) { + const { force = false, autoUpdate = false, silent = false } = options; + + // Check environment variables + if (process.env.SUPERCLAUDE_NO_UPDATE_CHECK === 'true') { + return false; + } + + // Check if enough time has passed + if (!shouldCheckUpdate(force)) { + return false; + } + + // Get current version + const currentVersion = getCurrentVersion(); + if (!currentVersion) { + return false; + } + + // Get latest version + const latestVersion = await getLatestVersion(); + if (!latestVersion) { + return false; + } + + // Save timestamp + saveCheckTimestamp(); + + // Compare versions + if (!isNewerVersion(currentVersion, latestVersion)) { + return false; + } + + // Show banner unless silent + if (!silent) { + const shouldUpdate = showUpdateBanner(currentVersion, latestVersion, autoUpdate); + + if (shouldUpdate) { + return performUpdate(); + } + } + + return false; +} + +// Export functions for use in other modules +module.exports = { + checkAndNotify, + getCurrentVersion, + getLatestVersion, + isNewerVersion +}; + +// If run directly, perform check +if (require.main === module) { + checkAndNotify({ + force: process.argv.includes('--force'), + autoUpdate: process.argv.includes('--auto-update') + }); +} \ No newline at end of file diff --git a/bin/cli.js b/bin/cli.js index 854bd25..9d5bcb1 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const { spawnSync } = require("child_process"); const { detectPython, detectPip } = require("./checkEnv"); +const { checkAndNotify } = require("./checkUpdate"); let pythonCmd = detectPython(); if (!pythonCmd) { @@ -10,12 +11,33 @@ if (!pythonCmd) { const args = process.argv.slice(2); +// Parse command line arguments for update control +const noUpdateCheck = args.includes('--no-update-check'); +const autoUpdate = args.includes('--auto-update'); +const isQuiet = args.includes('--quiet') || args.includes('-q'); + // Special case: update command if (args[0] === "update") { require("./update"); process.exit(0); } +// Check for updates unless disabled +if (!noUpdateCheck && !isQuiet) { + // Run update check asynchronously to avoid blocking + checkAndNotify({ + autoUpdate: autoUpdate, + silent: false + }).then(updated => { + if (updated) { + console.log("\n🔄 SuperClaude was updated. Please restart to use the new version."); + process.exit(0); + } + }).catch(() => { + // Silently ignore update check errors + }); +} + // Forward everything to Python SuperClaude const result = spawnSync(pythonCmd, ["-m", "SuperClaude", ...args], { stdio: "inherit", shell: true }); process.exit(result.status); diff --git a/setup/utils/updater.py b/setup/utils/updater.py new file mode 100644 index 0000000..836ef2c --- /dev/null +++ b/setup/utils/updater.py @@ -0,0 +1,310 @@ +""" +Auto-update checker for SuperClaude Framework +Checks PyPI for newer versions and offers automatic updates +""" + +import os +import sys +import json +import time +import subprocess +from pathlib import Path +from typing import Optional, Tuple +from packaging import version +import urllib.request +import urllib.error +from datetime import datetime, timedelta + +from .ui import display_info, display_warning, display_success, Colors +from .logger import get_logger + + +class UpdateChecker: + """Handles automatic update checking for SuperClaude""" + + PYPI_URL = "https://pypi.org/pypi/SuperClaude/json" + CACHE_FILE = Path.home() / ".claude" / ".update_check" + CHECK_INTERVAL = 86400 # 24 hours in seconds + TIMEOUT = 2 # seconds + + def __init__(self, current_version: str): + """ + Initialize update checker + + Args: + current_version: Current installed version + """ + self.current_version = current_version + self.logger = get_logger() + + def should_check_update(self, force: bool = False) -> bool: + """ + Determine if we should check for updates based on last check time + + Args: + force: Force check regardless of last check time + + Returns: + True if update check should be performed + """ + if force: + return True + + if not self.CACHE_FILE.exists(): + return True + + try: + with open(self.CACHE_FILE, 'r') as f: + data = json.load(f) + last_check = data.get('last_check', 0) + + # Check if 24 hours have passed + if time.time() - last_check > self.CHECK_INTERVAL: + return True + + except (json.JSONDecodeError, KeyError): + return True + + return False + + def save_check_timestamp(self): + """Save the current timestamp as last check time""" + self.CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + + data = {} + if self.CACHE_FILE.exists(): + try: + with open(self.CACHE_FILE, 'r') as f: + data = json.load(f) + except: + pass + + data['last_check'] = time.time() + + with open(self.CACHE_FILE, 'w') as f: + json.dump(data, f) + + def get_latest_version(self) -> Optional[str]: + """ + Query PyPI for the latest version of SuperClaude + + Returns: + Latest version string or None if check fails + """ + try: + # Create request with timeout + req = urllib.request.Request( + self.PYPI_URL, + headers={'User-Agent': 'SuperClaude-Updater'} + ) + + # Set timeout for the request + with urllib.request.urlopen(req, timeout=self.TIMEOUT) as response: + data = json.loads(response.read().decode()) + latest = data.get('info', {}).get('version') + + if self.logger: + self.logger.debug(f"Latest PyPI version: {latest}") + + return latest + + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as e: + if self.logger: + self.logger.debug(f"Failed to check PyPI: {e}") + return None + except Exception as e: + if self.logger: + self.logger.debug(f"Unexpected error checking updates: {e}") + return None + + def compare_versions(self, latest: str) -> bool: + """ + Compare current version with latest version + + Args: + latest: Latest version string + + Returns: + True if update is available + """ + try: + return version.parse(latest) > version.parse(self.current_version) + except Exception: + return False + + def detect_installation_method(self) -> str: + """ + Detect how SuperClaude was installed (pip, pipx, etc.) + + Returns: + Installation method string + """ + # Check pipx first + try: + result = subprocess.run( + ['pipx', 'list'], + capture_output=True, + text=True, + timeout=2 + ) + if 'SuperClaude' in result.stdout or 'superclaude' in result.stdout: + return 'pipx' + except: + pass + + # Check if pip installation exists + try: + result = subprocess.run( + [sys.executable, '-m', 'pip', 'show', 'SuperClaude'], + capture_output=True, + text=True, + timeout=2 + ) + if result.returncode == 0: + # Check if it's a user installation + if '--user' in result.stdout or Path.home() in Path(result.stdout): + return 'pip-user' + return 'pip' + except: + pass + + return 'unknown' + + def get_update_command(self) -> str: + """ + Get the appropriate update command based on installation method + + Returns: + Update command string + """ + method = self.detect_installation_method() + + commands = { + 'pipx': 'pipx upgrade SuperClaude', + 'pip-user': 'pip install --upgrade --user SuperClaude', + 'pip': 'pip install --upgrade SuperClaude', + 'unknown': 'pip install --upgrade SuperClaude' + } + + return commands.get(method, commands['unknown']) + + def show_update_banner(self, latest: str, auto_update: bool = False) -> bool: + """ + Display update available banner + + Args: + latest: Latest version available + auto_update: Whether to auto-update without prompting + + Returns: + True if user wants to update + """ + update_cmd = self.get_update_command() + + # Display banner + print(f"\n{Colors.CYAN}╔════════════════════════════════════════════════╗{Colors.RESET}") + print(f"{Colors.CYAN}║{Colors.YELLOW} 🚀 Update Available: {self.current_version} → {latest} {Colors.CYAN}║{Colors.RESET}") + print(f"{Colors.CYAN}║{Colors.GREEN} Run: {update_cmd:<30} {Colors.CYAN}║{Colors.RESET}") + print(f"{Colors.CYAN}╚════════════════════════════════════════════════╝{Colors.RESET}\n") + + if auto_update: + return True + + # Check if running in non-interactive mode + if not sys.stdin.isatty(): + return False + + # Prompt user + try: + response = input(f"{Colors.YELLOW}Would you like to update now? (y/N): {Colors.RESET}").strip().lower() + return response in ['y', 'yes'] + except (EOFError, KeyboardInterrupt): + return False + + def perform_update(self) -> bool: + """ + Execute the update command + + Returns: + True if update succeeded + """ + update_cmd = self.get_update_command() + + print(f"{Colors.CYAN}🔄 Updating SuperClaude...{Colors.RESET}") + + try: + result = subprocess.run( + update_cmd.split(), + capture_output=False, + text=True + ) + + if result.returncode == 0: + display_success("Update completed successfully!") + print(f"{Colors.YELLOW}Please restart SuperClaude to use the new version.{Colors.RESET}") + return True + else: + display_warning("Update failed. Please run manually:") + print(f" {update_cmd}") + return False + + except Exception as e: + display_warning(f"Could not auto-update: {e}") + print(f"Please run manually: {update_cmd}") + return False + + def check_and_notify(self, force: bool = False, auto_update: bool = False) -> bool: + """ + Main method to check for updates and notify user + + Args: + force: Force check regardless of last check time + auto_update: Automatically update if available + + Returns: + True if update was performed + """ + # Check if we should skip based on environment variable + if os.getenv('SUPERCLAUDE_NO_UPDATE_CHECK', '').lower() in ['true', '1', 'yes']: + return False + + # Check if auto-update is enabled via environment + if os.getenv('SUPERCLAUDE_AUTO_UPDATE', '').lower() in ['true', '1', 'yes']: + auto_update = True + + # Check if enough time has passed + if not self.should_check_update(force): + return False + + # Get latest version + latest = self.get_latest_version() + if not latest: + return False + + # Save timestamp + self.save_check_timestamp() + + # Compare versions + if not self.compare_versions(latest): + return False + + # Show banner and potentially update + if self.show_update_banner(latest, auto_update): + return self.perform_update() + + return False + + +def check_for_updates(current_version: str = "4.0.6", **kwargs) -> bool: + """ + Convenience function to check for updates + + Args: + current_version: Current installed version + **kwargs: Additional arguments passed to check_and_notify + + Returns: + True if update was performed + """ + checker = UpdateChecker(current_version) + return checker.check_and_notify(**kwargs) \ No newline at end of file