Add automatic update checking for PyPI and NPM packages

- Check for updates on startup (once per 24h)
- Show update banner when new version available
- Support --no-update-check and --auto-update flags
- Add SUPERCLAUDE_AUTO_UPDATE environment variable
- Implement for both Python (PyPI) and Node.js (NPM)
This commit is contained in:
NomenAK 2025-08-23 12:50:20 +02:00
parent 06ee059c0b
commit 291b8a0c2b
4 changed files with 632 additions and 0 deletions

View File

@ -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
@ -199,6 +203,26 @@ def main() -> int:
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:
if not args.quiet:

276
bin/checkUpdate.js Normal file
View File

@ -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')
});
}

View File

@ -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);

310
setup/utils/updater.py Normal file
View File

@ -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)