feat: enhance subscription management with backup option and update CLI commands

This commit is contained in:
Klesh Wong
2026-05-26 12:56:37 +08:00
parent e799ea011f
commit 302d4e6bb5
7 changed files with 156 additions and 33 deletions

1
.gitignore vendored
View File

@ -129,7 +129,6 @@ dmypy.json
.pyre/
# IDE
.vscode/
.idea/
*.swp
*.swo

15
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "ss: subscription list",
"type": "debugpy",
"request": "launch",
"module": "ss",
"args": ["subscription", "list"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"justMyCode": true
}
]
}

76
AGENTS.md Normal file
View File

@ -0,0 +1,76 @@
# AGENTS.md
## Overview
This document describes the autonomous agents, subagents, and their roles within the scientific-surfing project.
---
## Agent List
### 1. SubscriptionManager Agent
- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, and activating subscriptions.
- **Key Methods:**
- `add_subscription`
- `refresh_subscription`
- `delete_subscription`
- `rename_subscription`
- `set_subscription_url`
- `activate_subscription`
- `list_subscriptions`
- **Notes:** Supports backup option on refresh.
### 2. StorageManager Agent
- **Purpose:** Manages persistent storage for configuration and subscription data.
- **Key Methods:**
- `load_subscriptions`
- `save_subscriptions`
- `load_config`
- `get_storage_info`
### 3. CoreConfigManager Agent
- **Purpose:** Handles core configuration management, including import/export, editing, resetting, and applying configuration.
- **Key Methods:**
- `import_config`
- `export_config`
- `edit_config`
- `reset_config`
- `show_config`
- `apply`
### 4. CoreManager Agent
- **Purpose:** Manages core components and system service operations (install, uninstall, start, stop, restart, reload, status, update).
- **Key Methods:**
- `install_service`
- `uninstall_service`
- `start_service`
- `stop_service`
- `restart_service`
- `reload_service`
- `get_service_status`
- `update`
### 5. HookManager Agent
- **Purpose:** Manages hook scripts for automation and customization.
- **Key Methods:**
- `init`
- `list_hooks`
- `edit`
- `rm`
---
## Agent Interactions
- The CLI acts as the orchestrator, parsing user commands and delegating tasks to the appropriate agent.
- Agents interact via method calls and shared data models.
---
## Extending Agents
- To add a new agent, implement a new manager class and update the CLI to route commands.
- Document new agents and their responsibilities in this file.
---
## Last updated
May 26, 2026

View File

@ -29,8 +29,8 @@ pip install -r requirements.txt
# add a subscription
python -m ss subscription add <name> <clash-rss-subscription-url>
# refresh a subscription
python -m ss subscription refresh <name>
# refresh a subscription (with optional backup)
python -m ss subscription refresh <name> [--backup]
# delete a subscription
python -m ss subscription rm <name>
@ -69,23 +69,30 @@ python -m ss hook rm <script-name>
### Core Configuration Management
```bash
# import configuration from file
python -m ss core config import <file-path>
python -m ss core config import <file-path> [--config-file <config.yaml>]
# export configuration to file
python -m ss core config export <file-path>
python -m ss core config export <file-path> [--config-file <config.yaml>]
# edit configuration with system editor
python -m ss core config edit
python -m ss core config edit [--config-file <config.yaml>]
# reset configuration to default values
python -m ss core config reset
python -m ss core config reset [--config-file <config.yaml>]
# show current configuration
python -m ss core config show
python -m ss core config show [--config-file <config.yaml>]
# apply active subscription to generate final config
python -m ss core config apply
# apply subscription to generate final config (with advanced options)
python -m ss core config apply \
[--config-file <config.yaml>] \
[--output-file <output.yaml>] \
[--subscription <subscription-name>]
```
**Options:**
- `--config-file <config.yaml>`: Use a custom config file instead of the default.
- `--output-file <output.yaml>`: Specify the output path for the generated config file.
- `--subscription <subscription-name>`: Use a specific subscription (not just the active one) for config generation.
### Core Management
```bash

View File

@ -30,6 +30,7 @@ def create_parser() -> argparse.ArgumentParser:
# Refresh subscription command
refresh_parser = subscription_subparsers.add_parser('refresh', help='Refresh a subscription')
refresh_parser.add_argument('name', help='Name of the subscription to refresh')
refresh_parser.add_argument('--backup', action='store_true', help='Backup the existing file before refreshing')
# Delete subscription command (rm)
delete_parser = subscription_subparsers.add_parser('rm', help='Delete a subscription')
@ -67,6 +68,9 @@ def create_parser() -> argparse.ArgumentParser:
# Config commands
config_parser = subparsers.add_parser('config', help='Manage core configuration')
config_subparsers = config_parser.add_subparsers(dest='config_command', help='Configuration operations')
config_parser.add_argument('--config-file', type=str, default=None, help='Path to the user config YAML file (default: core-config.yaml in config dir)')
config_parser.add_argument('--output-file', type=str, default=None, help='Path to the generated config file (default: generated_config.yaml in config dir)')
config_parser.add_argument('--subscription', type=str, default=None, help='Name of the subscription to use for config generation (default: active subscription)')
# Import config
import_parser = config_subparsers.add_parser('import', help='Import configuration from file')
@ -151,7 +155,8 @@ def handle_subscription_command(args, subscription_manager: SubscriptionManager,
if args.subcommand == 'add':
subscription_manager.add_subscription(args.name, args.url)
elif args.subcommand == 'refresh':
subscription_manager.refresh_subscription(args.name)
subscription_manager.refresh_subscription(args.name, backup=getattr(args, 'backup', False))
core_manager.reload_service()
elif args.subcommand == 'rm':
subscription_manager.delete_subscription(args.name)
elif args.subcommand == 'rename':
@ -274,7 +279,13 @@ def main() -> None:
storage = StorageManager()
subscription_manager = SubscriptionManager(storage)
core_config_manager = CoreConfigManager(subscription_manager)
# Pass config_file argument to CoreConfigManager if provided
core_config_manager = CoreConfigManager(
subscription_manager,
config_file=getattr(args, 'config_file', None),
output_file=getattr(args, 'output_file', None),
subscription_name=getattr(args, 'subscription', None)
)
core_manager = CoreManager(core_config_manager)
hook_manager = HookManager(storage)

View File

@ -19,10 +19,18 @@ from .utils import open_file_in_editor
class CoreConfigManager:
"""Manages user configuration with import, export, and edit operations."""
def __init__(self, subscription_manager: SubscriptionManager):
def __init__(self, subscription_manager: SubscriptionManager, config_file: str = None, output_file: str = None, subscription_name: str = None):
self.subscription_manager = subscription_manager
self.storage = subscription_manager.storage
self.config_file = self.storage.config_dir / "core-config.yaml"
if config_file:
self.config_file = Path(config_file).expanduser().resolve()
else:
self.config_file = self.storage.config_dir / "core-config.yaml"
if output_file:
self.generated_path = Path(output_file).expanduser().resolve()
else:
self.generated_path = self.storage.config_dir / "generated_config.yaml"
self.subscription_name = subscription_name
self.default_config_path = Path(__file__).parent / "templates" / "default-core-config.yaml"
def _ensure_config_exists(self) -> bool:
@ -258,17 +266,22 @@ class CoreConfigManager:
# Load current configuration
config = self.load_config()
# Load subscriptions to get active subscription
active_subscription = self.subscription_manager.subscriptions_data.get_active_subscription()
# Load subscriptions to get the selected or active subscription
if self.subscription_name:
subscription = self.subscription_manager.subscriptions_data.subscriptions.get(self.subscription_name)
if not subscription:
print(f"❌ Subscription '{self.subscription_name}' not found")
return False
else:
subscription = self.subscription_manager.subscriptions_data.get_active_subscription()
if not subscription:
print("❌ No active subscription found")
return False
if not active_subscription:
print("❌ No active subscription found")
return False
file_path = active_subscription.get_file_path(self.storage.config_dir)
file_path = subscription.get_file_path(self.storage.config_dir)
if not file_path or not Path(file_path).exists():
print("Active subscription file not found. Please refresh the subscription first.")
print(f"Subscription file not found for '{subscription.name}'. Please refresh the subscription first.")
return False
try:
@ -322,16 +335,14 @@ class CoreConfigManager:
}
# Generate final config file
generated_path = self.storage.config_dir / "generated_config.yaml"
with open(generated_path, 'w', encoding='utf-8') as f:
with open(self.generated_path, 'w', encoding='utf-8') as f:
yaml.dump(final_config, f, default_flow_style=False, allow_unicode=True)
print(f"✅ Generated final configuration: {generated_path}")
print(f" Active subscription: {active_subscription.name}")
print(f"✅ Generated final configuration: {self.generated_path}")
print(f" Active subscription: {subscription.name}")
# Execute hooks after successful config generation
self._execute_hooks(generated_path)
self._execute_hooks(self.generated_path)
return True

View File

@ -243,8 +243,12 @@ class SubscriptionManager:
return content
def refresh_subscription(self, name: str) -> None:
"""Refresh a subscription by downloading from URL."""
def refresh_subscription(self, name: str, backup: bool = False) -> None:
"""Refresh a subscription by downloading from URL.
Args:
name (str): The subscription name.
backup (bool, optional): Whether to backup the existing file. Defaults to False.
"""
if name not in self.subscriptions_data.subscriptions:
print(f"❌ Subscription '{name}' not found")
return
@ -262,15 +266,15 @@ class SubscriptionManager:
# File path without timestamp
file_path = self.subscriptions_dir / f"{name}.yml"
# Handle existing file by renaming with creation date
if file_path.exists():
# Handle existing file by renaming with creation date if backup is enabled
if backup and file_path.exists():
# Get creation time of existing file
stat = file_path.stat()
try:
# Try st_birthtime first (macOS/Unix)
creation_time = datetime.fromtimestamp(stat.st_birthtime)
except AttributeError:
# Fallback to st_ctime (Windows)
# Fallback to st_ctime (Windows/Linux)
creation_time = datetime.fromtimestamp(stat.st_ctime)
backup_name = f"{name}_{creation_time.strftime('%Y%m%d_%H%M%S')}.yml"