feat: Add new tools and configurations for Castle platform

- Introduced `uv.lock` for dependency management with various packages including `pytest`, `colorama`, and `pluggy`.
- Added `pyrightconfig.json` for Python type checking configuration.
- Expanded `recommendations.md` with detailed scaling recommendations and project management strategies.
- Created shared `ruff.toml` for consistent linting across projects.
- Developed `Castle Tools` with various utilities including Android backup, browser automation, document conversion, and search tools.
- Implemented `backup-collect` and `schedule` tools for system administration tasks.
- Enhanced `search` functionality with indexing and querying capabilities using Tantivy.
- Added comprehensive documentation for each tool, including usage examples and installation instructions.
This commit is contained in:
2026-02-20 16:41:19 -08:00
parent 0d35ac9ffd
commit f39a551aad
152 changed files with 21197 additions and 83 deletions

View File

@@ -0,0 +1,17 @@
[project]
name = "castle-system"
version = "0.1.0"
description = "Castle system administration tools"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
backup-collect = "system.backup_collect:main"
schedule = "system.schedule:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/system"]

View File

View File

@@ -0,0 +1,137 @@
---
command: backup-collect
script: system/backup-collect.py
description: Collect files from various sources into backup directory
version: 1.0.0
category: system
system_dependencies:
- rsync
---
# backup-collect
Collect system information and configuration files for backup purposes.
## Installation
```bash
cd /data/repos/toolkit
make install
which backup-collect
```
## Usage
```bash
# Collect system information
backup-collect
# Specify output directory
backup-collect -o /path/to/backup
# Collect specific categories
backup-collect --category config
backup-collect --category packages
```
### Options
```bash
backup-collect [options]
Options:
-o, --output DIR Output directory for collected data
--category CAT Collect specific category (config, packages, system)
-v, --verbose Enable verbose output
-h, --help Show help message
```
## What It Collects
### System Information
- OS version and details
- Kernel information
- Hardware details
- System uptime
### Configuration Files
- User dotfiles (.bashrc, .vimrc, etc.)
- Application configs
- SSH keys (with permissions preserved)
- Custom scripts
### Package Lists
- Installed system packages
- Python packages
- npm packages
- Other package managers
## Features
- **Comprehensive** - Collects essential system data
- **Organized** - Structured output directory
- **Safe** - Preserves permissions
- **Selective** - Choose what to collect
- **Portable** - Outputs in standard formats
## Examples
### Full Backup
```bash
backup-collect -o ~/backups/system-$(date +%Y%m%d)
```
### Scheduled Backup
```bash
# Weekly system backup
schedule add system-backup \
--command "backup-collect -o /mnt/backups/system-\$(date +\%Y\%m\%d)" \
--schedule "weekly" \
--on-calendar "Sun 02:00:00"
```
### Selective Collection
```bash
# Only collect configs
backup-collect --category config -o ~/configs
# Only package lists
backup-collect --category packages -o ~/packages
```
## Output Structure
```
backup-directory/
├── system/
│ ├── os-release
│ ├── kernel-version
│ └── hardware-info
├── config/
│ ├── dotfiles/
│ ├── ssh/
│ └── app-configs/
└── packages/
├── apt-packages.txt
├── pip-packages.txt
└── npm-packages.txt
```
## Use Cases
- **System migration** - Document current setup
- **Disaster recovery** - Quick system restoration
- **Configuration management** - Track system changes
- **Compliance** - Document installed software
- **Development** - Share development environment setup
## Notes
- Does not collect sensitive data by default
- SSH keys are collected but not passwords
- Large files are skipped
- Runs without root (collects what's accessible)
- Safe to run frequently

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
backup-collect: Collect files from various sources into backup directory
Incrementally backs up specified directories to the central backup location
using rsync. Creates hardlinks to unchanged files from previous backups to
save disk space.
Usage: backup-collect [options]
Examples:
backup-collect # Backup using default configuration
backup-collect -c custom.json # Use custom configuration file
backup-collect --full # Perform a full backup instead of incremental
"""
import sys
import os
import json
import argparse
import subprocess
import datetime
import logging
# Constants
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/toolkit")
DEFAULT_CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, "backup-config.json")
DEFAULT_BACKUP_DIR = os.environ.get("DBACKUP", "/data/backup")
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("backup-collect")
def create_default_config(config_path):
"""Create a default configuration file if none exists."""
default_config = {
"sources": [
{
"name": "config",
"paths": [
os.path.expanduser("~/.config"),
],
"exclusions": [
os.path.expanduser("~/.config/Code/Cache/*"),
"*/Cache/*",
"*/CacheStorage/*",
],
}
],
"retention": {"local": 7},
"schedule": {"incremental": "daily", "full": "weekly"},
}
# Create config directory if it doesn't exist
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, "w") as f:
json.dump(default_config, f, indent=2)
logger.info(f"Created default configuration at {config_path}")
return default_config
def load_config(config_path):
"""Load the configuration file or create default if it doesn't exist."""
if not os.path.exists(config_path):
return create_default_config(config_path)
try:
with open(config_path, "r") as f:
config = json.load(f)
logger.info(f"Loaded configuration from {config_path}")
return config
except Exception as e:
logger.error(f"Error loading configuration: {e}")
logger.info("Using default configuration")
return create_default_config(config_path + ".default")
def run_backup(source, backup_dir, date_str, incremental=True):
"""Run rsync backup for a given source configuration."""
source_name = source["name"]
source_paths = source["paths"]
exclusions = source.get("exclusions", [])
# Create dated and latest directory names
dated_dir = os.path.join(backup_dir, f"{source_name}-{date_str}")
latest_dir = os.path.join(backup_dir, f"{source_name}-latest")
previous_dir = os.path.join(backup_dir, f"{source_name}-previous")
logger.info(f"Backing up {source_name} to {dated_dir}")
# Create backup directory if it doesn't exist
os.makedirs(dated_dir, exist_ok=True)
for source_path in source_paths:
# Expand user paths like ~/.config
expanded_path = os.path.expanduser(source_path)
if not os.path.exists(expanded_path):
logger.warning(f"Source path does not exist: {expanded_path}")
continue
# Prepare rsync command
rsync_cmd = ["rsync", "-av", "--delete", "--block-size=131072"]
# Add exclusions
for exclusion in exclusions:
rsync_cmd.extend(["--exclude", exclusion])
# For incremental backups, use hardlinks to previous backup
if incremental and os.path.exists(latest_dir):
rsync_cmd.extend(["--link-dest", latest_dir])
# Add source and destination with path preservation
if os.path.isdir(expanded_path):
# For directories, preserve the trailing slash behavior
source_with_slash = (
expanded_path if expanded_path.endswith("/") else expanded_path + "/"
)
rsync_cmd.extend(["-R", source_with_slash]) # -R preserves relative path
rsync_cmd.append(dated_dir + "/")
else:
# For files, preserve the full path structure
rsync_cmd.extend(["-R", expanded_path]) # -R preserves relative path
rsync_cmd.append(dated_dir + "/")
# Execute rsync
logger.info(f"Running: {' '.join(rsync_cmd)}")
try:
# Use check=False to continue even if there are errors
result = subprocess.run(
rsync_cmd, check=False, capture_output=True, text=True
)
if result.returncode == 0:
logger.info(f"Backup of {expanded_path} completed successfully")
else:
logger.error(f"Backup failed for {expanded_path}: {result.stderr}")
return False
except Exception as e:
logger.error(f"Backup failed for {expanded_path}: {e}")
return False
# Update symlinks
try:
# Move previous latest to previous if it exists
if os.path.exists(latest_dir) and os.path.islink(latest_dir):
# Remove existing previous symlink first
if os.path.exists(previous_dir) or os.path.islink(previous_dir):
os.unlink(previous_dir)
os.symlink(os.readlink(latest_dir), previous_dir)
# Update latest symlink
if os.path.exists(latest_dir) or os.path.islink(latest_dir):
os.unlink(latest_dir)
os.symlink(os.path.basename(dated_dir), latest_dir)
logger.info(f"Updated symlinks for {source_name}")
except Exception as e:
logger.error(f"Failed to update symlinks: {e}")
return False
return True
def cleanup_old_backups(source, backup_dir, retention):
"""Remove old backups beyond the retention period."""
source_name = source["name"]
logger.info(f"Cleaning up old backups for {source_name}")
try:
# Find all dated backups for this source
backups = sorted(
[
d
for d in os.listdir(backup_dir)
if os.path.isdir(os.path.join(backup_dir, d))
and d.startswith(f"{source_name}-")
and not os.path.islink(os.path.join(backup_dir, d))
]
)
# Keep only the most recent 'retention' number of backups
if len(backups) > retention:
to_delete = backups[:-retention]
for backup in to_delete:
backup_path = os.path.join(backup_dir, backup)
logger.info(f"Removing old backup: {backup_path}")
# Use subprocess to remove directories that might have read-only files
subprocess.run(["rm", "-rf", backup_path], check=True)
logger.info(f"Cleanup completed, kept {min(len(backups), retention)} backups")
except Exception as e:
logger.error(f"Error during cleanup: {e}")
return False
return True
def main():
parser = argparse.ArgumentParser(
description="Collect files from various sources into backup directory",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split("\n\n", 1)[1]
if __doc__
else "", # Use the docstring as extended help
)
parser.add_argument(
"-c", "--config", help=f"Path to config file (default: {DEFAULT_CONFIG_FILE})"
)
parser.add_argument(
"-b",
"--backup-dir",
help=f"Backup directory (default: $DBACKUP or {DEFAULT_BACKUP_DIR})",
)
parser.add_argument(
"-f",
"--full",
action="store_true",
help="Perform a full backup instead of incremental",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
parser.add_argument(
"-d", "--debug", action="store_true", help="Enable debug output"
)
parser.add_argument("--version", action="version", version="backup-collect 1.0.0")
args = parser.parse_args()
# Set verbosity
if args.verbose or args.debug:
logger.setLevel(logging.DEBUG)
# Extra debug info
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
# Load configuration
config_path = args.config or DEFAULT_CONFIG_FILE
config = load_config(config_path)
# Determine backup directory
backup_dir = args.backup_dir or DEFAULT_BACKUP_DIR
os.makedirs(backup_dir, exist_ok=True)
logger.info(f"Using backup directory: {backup_dir}")
# Current date for backup naming
date_str = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
# Determine if using incremental backup
incremental = not args.full
logger.info(f"Performing {'incremental' if incremental else 'full'} backup")
success = True
# Debug output of all config
if args.debug:
logger.debug(f"Using config: {json.dumps(config, indent=2)}")
logger.debug(f"Backup dir: {backup_dir}")
logger.debug(f"Sources to backup: {len(config.get('sources', []))}")
for source in config.get("sources", []):
logger.debug(f"Source: {source['name']}, Paths: {source['paths']}")
# Process each source
for source in config.get("sources", []):
logger.info(f"Processing source: {source['name']}")
if not run_backup(source, backup_dir, date_str, incremental):
logger.error(f"Backup failed for {source['name']}")
success = False
# Cleanup old backups
retention = config.get("retention", {}).get("local", 7)
cleanup_old_backups(source, backup_dir, retention)
if success:
logger.info("✅ All backups completed successfully")
return 0
else:
logger.error("One or more backups failed")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,388 @@
# schedule
A tool to easily create, manage, and monitor systemd user timers and services following best practices.
## Overview
`schedule` simplifies the management of systemd user timers by providing a simple command-line interface to create timer/service pairs that execute bash commands on a schedule. It handles all the boilerplate configuration and follows systemd best practices for security and resource management.
## Installation
Install the toolkit:
```bash
cd /data/repos/toolkit
make install
```
The `schedule` command will be available in your PATH.
### Enable Linger (Optional but Recommended)
By default, systemd user units only run while you're logged in. To allow timers to run even when you're logged out, enable linger:
```bash
loginctl enable-linger $USER
```
This is essential for timers that should run 24/7, like automated sync jobs.
## Features
- **Easy timer creation** - Create timers with a single command
- **Multiple schedule types** - Support for interval-based and calendar-based schedules
- **Environment variables** - Support for environment files and inline variables
- **Pre-conditions** - Run commands only when conditions are met
- **Security hardening** - Automatic security settings (NoNewPrivileges, PrivateTmp)
- **Log management** - Easy access to service logs via journalctl
- **Status monitoring** - Check timer and service status
## Usage
### List timers
Show all active user timers:
```bash
schedule list
```
Show all timers including inactive ones:
```bash
schedule list --all
```
### Add a timer
Basic timer that runs every 5 minutes:
```bash
schedule add my-task \
--command "echo 'Hello, World!'" \
--schedule "5min"
```
Daily backup at 2 AM:
```bash
schedule add daily-backup \
--command "backup run" \
--schedule "daily" \
--on-calendar "*-*-* 02:00:00" \
--description "Daily backup job"
```
ProtonMail sync with environment file and condition:
```bash
schedule add protonmail-sync \
--command "protonmail sync" \
--schedule "5min" \
--description "Sync ProtonMail emails" \
--env-file ~/.config/protonmail/env \
--condition-command "pgrep -f protonmail-bridge"
```
With environment variables:
```bash
schedule add my-task \
--command "my-script.sh" \
--schedule "1hour" \
--environment "API_KEY=secret123" \
--environment "LOG_LEVEL=debug" \
--working-directory "/home/user/projects"
```
### Check status
View timer and service status:
```bash
schedule status protonmail-sync
```
This shows:
- Timer status (active/inactive)
- Service status (last run, next run)
- Next scheduled execution time
### View logs
Follow logs in real-time:
```bash
schedule logs protonmail-sync --follow
```
Show last 50 lines:
```bash
schedule logs protonmail-sync --lines 50
```
Show logs since today:
```bash
schedule logs protonmail-sync --since today
```
### Start/Stop timers
Start a timer:
```bash
schedule start protonmail-sync
```
Stop a timer:
```bash
schedule stop protonmail-sync
```
### Enable/Disable timers
Enable timer (start on boot):
```bash
schedule enable protonmail-sync
```
Disable timer (don't start on boot):
```bash
schedule disable protonmail-sync
```
### Remove a timer
Remove timer and service files:
```bash
schedule remove protonmail-sync
```
Remove but keep logs:
```bash
schedule remove protonmail-sync --keep-logs
```
## Schedule Formats
### Interval-based schedules
Use simple time units:
- `5min` - Every 5 minutes
- `1hour` or `1h` - Every hour
- `30min` - Every 30 minutes
- `1day` or `1d` - Every day
- `1week` or `1w` - Every week
- `hourly` - Every hour (alias for 1hour)
- `daily` - Every day (alias for 1day)
### Calendar-based schedules
Use systemd calendar syntax with `--on-calendar`:
- `*-*-* 02:00:00` - Every day at 2 AM
- `Mon *-*-* 09:00:00` - Every Monday at 9 AM
- `*-*-01 00:00:00` - First day of each month at midnight
- `*-*-* *:00:00` - Every hour on the hour
## Advanced Options
### Add command options
- `--name` - Name of the timer/service (required)
- `--command` - Bash command to execute (required)
- `--schedule` - Schedule format (required)
- `--description` - Human-readable description
- `--env-file` - Path to environment file to source
- `--environment` / `-e` - Set environment variable (KEY=VALUE)
- `--condition-command` - Command that must succeed before running
- `--working-directory` - Working directory for the command
- `--on-boot-sec` - Delay after boot (default: 1min)
- `--on-calendar` - Override with calendar-based schedule
- `--persistent` - Run missed timers if system was offline
- `--force` - Overwrite existing timer/service
- `--no-enable` - Don't enable the timer
- `--no-start` - Don't start the timer
## Examples
### ProtonMail Sync
```bash
schedule add protonmail-sync \
--command "protonmail sync" \
--schedule "5min" \
--description "Sync ProtonMail emails every 5 minutes" \
--env-file ~/.config/protonmail/env \
--condition-command "pgrep -f protonmail-bridge"
```
### Daily Backup
```bash
schedule add daily-backup \
--command "backup run --full" \
--schedule "daily" \
--on-calendar "*-*-* 02:00:00" \
--description "Daily backup at 2 AM" \
--environment "BACKUP_DIR=/mnt/backups"
```
### Hourly Data Sync
```bash
schedule add data-sync \
--command "rsync -av /source/ /dest/" \
--schedule "hourly" \
--description "Sync data every hour" \
--working-directory "/home/user/sync"
```
### Git Auto-Commit
```bash
schedule add git-autocommit \
--command "git add -A && git commit -m 'Auto-commit' && git push" \
--schedule "15min" \
--description "Auto-commit and push changes" \
--working-directory "/home/user/projects/myrepo"
```
## File Locations
Timer and service files are stored in:
```
~/.config/systemd/user/
├── <name>.service
└── <name>.timer
```
## Security Features
All services created by `schedule` include:
- `NoNewPrivileges=yes` - Cannot gain new privileges
- `PrivateTmp=yes` - Isolated /tmp directory
- Proper PATH configuration
- Journal logging for stdout/stderr
## Troubleshooting
### Timer not running
Check if the timer is enabled and active:
```bash
schedule status <name>
```
### Service failing
View the logs to see what went wrong:
```bash
schedule logs <name> --lines 50
```
### Command not found
Ensure the command is in the PATH or use an absolute path:
```bash
schedule add my-task \
--command "/usr/local/bin/my-script" \
--schedule "5min"
```
### Environment variables not working
Use an environment file:
```bash
# Create env file
echo "API_KEY=secret" > ~/.config/myapp/env
chmod 600 ~/.config/myapp/env
# Use it
schedule add my-task \
--command "my-script" \
--schedule "5min" \
--env-file ~/.config/myapp/env
```
## Comparison with Manual Setup
Instead of manually creating:
1. `.service` file with proper configuration
2. `.timer` file with scheduling
3. Running `systemctl --user daemon-reload`
4. Running `systemctl --user enable <name>.timer`
5. Running `systemctl --user start <name>.timer`
You can do it all with one command:
```bash
schedule add <name> --command "<cmd>" --schedule "<schedule>"
```
## See Also
- `man systemd.timer` - systemd timer documentation
- `man systemd.service` - systemd service documentation
- `man systemd.time` - systemd time specification
- `journalctl` - Query systemd journal
## Important Notes
### Environment Variables
**Critical**: Systemd user units do NOT inherit environment variables from your shell (e.g., ~/.bashrc). Variables set with `export` in your shell will not be available to services.
To make environment variables available to your scheduled commands, pass them inline when creating the timer:
```bash
schedule add my-task \
--command "my-script" \
--schedule "1hour" \
--environment "API_KEY=secret123" \
--environment "DATABASE_URL=postgres://localhost/mydb"
```
You can also use the `-e` shorthand:
```bash
schedule add my-task \
--command "my-script" \
--schedule "1hour" \
-e "API_KEY=secret123" \
-e "DATABASE_URL=postgres://localhost/mydb"
```
### User vs System Units
This tool creates **user** systemd units (`systemctl --user`), not system units. User units:
- Run as your user (no root required)
- Stored in `~/.config/systemd/user/`
- Only run while you're logged in (unless linger is enabled)
- Have access to your user's files and permissions
To enable timers to run 24/7 even when logged out:
```bash
loginctl enable-linger $USER
```
## License
Same license as the toolkit project.

View File

@@ -0,0 +1,552 @@
#!/usr/bin/env python3
"""Schedule - Systemd timer and service management tool.
A tool to easily create, manage, and monitor systemd user timers and services
following best practices.
Usage:
schedule list [--all]
schedule add <name> --command <cmd> --schedule <schedule> [options]
schedule remove <name> [--keep-logs]
schedule status <name>
schedule logs <name> [--follow] [--lines N]
schedule enable <name>
schedule disable <name>
schedule start <name>
schedule stop <name>
Examples:
# List all user timers
schedule list
# Create a timer for ProtonMail sync every 5 minutes
schedule add protonmail-sync \\
--command "protonmail sync" \\
--schedule "5min" \\
--description "Sync ProtonMail emails" \\
--env-file ~/.config/protonmail/env \\
--condition-command "pgrep -f protonmail-bridge"
# Create a daily backup timer at 2 AM
schedule add daily-backup \\
--command "backup run" \\
--schedule "daily" \\
--on-calendar "*-*-* 02:00:00"
# Check status of a timer
schedule status protonmail-sync
# View logs in real-time
schedule logs protonmail-sync --follow
# Remove a timer
schedule remove protonmail-sync
"""
import argparse
import subprocess
import sys
from pathlib import Path
def get_systemd_user_dir() -> Path:
"""Get the systemd user directory."""
return Path.home() / ".config" / "systemd" / "user"
def run_systemctl(
args: list[str], check: bool = True
) -> subprocess.CompletedProcess[str]:
"""Run systemctl command with user flag."""
cmd = ["systemctl", "--user"] + args
return subprocess.run(
cmd,
capture_output=True,
text=True,
check=check,
)
def list_timers(all_timers: bool = False) -> None:
"""List all user timers."""
args = ["list-timers"]
if all_timers:
args.append("--all")
result = run_systemctl(args, check=False)
print(result.stdout)
if result.returncode != 0:
print(f"Error: {result.stderr}", file=sys.stderr)
sys.exit(1)
def generate_service_content(
name: str,
command: str,
description: str | None = None,
env_file: str | None = None,
condition_command: str | None = None,
working_directory: str | None = None,
environment: dict[str, str] | None = None,
) -> str:
"""Generate systemd service file content."""
desc = description or f"{name} service"
content = f"""[Unit]
Description={desc}
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
"""
# Add default PATH
content += 'Environment="PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin"\n'
# Add additional environment variables
if environment:
for key, value in environment.items():
content += f'Environment="{key}={value}"\n'
# Add environment file if specified
if env_file:
content += f"EnvironmentFile={env_file}\n"
content += "\n"
# Add condition command if specified
if condition_command:
content += "# Pre-condition check\n"
# Use double quotes to avoid conflicts with single quotes in the command
escaped_condition = condition_command.replace('"', '\\"')
content += f'ExecStartPre=/bin/bash -c "{escaped_condition}"\n\n'
# Set working directory if specified
if working_directory:
content += f"WorkingDirectory={working_directory}\n"
# Add the main command
content += "# Run the command\n"
# Use double quotes to avoid conflicts with single quotes in the command
escaped_command = command.replace('"', '\\"')
content += f'ExecStart=/bin/bash -c "{escaped_command}"\n\n'
# Logging
content += """# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier={name}
# Security settings
NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=default.target
""".format(name=name)
return content
def generate_timer_content(
name: str,
description: str | None = None,
on_boot_sec: str = "1min",
on_unit_active_sec: str | None = None,
on_calendar: str | None = None,
persistent: bool = False,
) -> str:
"""Generate systemd timer file content."""
desc = description or f"{name} timer"
content = f"""[Unit]
Description={desc}
Requires={name}.service
[Timer]
"""
# Add boot delay
if on_boot_sec:
content += f"OnBootSec={on_boot_sec}\n"
# Add interval-based schedule
if on_unit_active_sec:
content += f"OnUnitActiveSec={on_unit_active_sec}\n"
# Add calendar-based schedule
if on_calendar:
content += f"OnCalendar={on_calendar}\n"
content += f"""
# If the system was offline, {"run" if persistent else "don't run"} missed timers
Persistent={"true" if persistent else "false"}
[Install]
WantedBy=timers.target
"""
return content
def parse_schedule(schedule: str) -> tuple[str | None, str | None]:
"""Parse schedule string into timer parameters.
Returns:
Tuple of (on_unit_active_sec, on_calendar)
"""
schedule_lower = schedule.lower()
# Simple interval schedules
if any(
schedule_lower.endswith(unit)
for unit in ["min", "hour", "h", "day", "d", "week", "w"]
):
return schedule, None
# Daily at specific time
if schedule_lower == "daily":
return "1day", None
# Hourly
if schedule_lower == "hourly":
return "1hour", None
# Assume it's a calendar spec
return None, schedule
def add_timer(args: argparse.Namespace) -> None:
"""Add a new systemd timer and service."""
name = args.name
command = args.command
schedule = args.schedule
# Parse schedule
on_unit_active_sec, on_calendar = parse_schedule(schedule)
if args.on_calendar:
on_calendar = args.on_calendar
on_unit_active_sec = None
# Create systemd directory if it doesn't exist
systemd_dir = get_systemd_user_dir()
systemd_dir.mkdir(parents=True, exist_ok=True)
# Generate service file
environment = {}
if args.environment:
for env in args.environment:
key, value = env.split("=", 1)
environment[key] = value
service_content = generate_service_content(
name=name,
command=command,
description=args.description,
env_file=args.env_file,
condition_command=args.condition_command,
working_directory=args.working_directory,
environment=environment,
)
# Generate timer file
timer_content = generate_timer_content(
name=name,
description=args.description,
on_boot_sec=args.on_boot_sec,
on_unit_active_sec=on_unit_active_sec,
on_calendar=on_calendar,
persistent=args.persistent,
)
# Write service file
service_path = systemd_dir / f"{name}.service"
timer_path = systemd_dir / f"{name}.timer"
# Check if files already exist
if service_path.exists() and not args.force:
print(f"Error: Service file already exists: {service_path}", file=sys.stderr)
print("Use --force to overwrite", file=sys.stderr)
sys.exit(1)
if timer_path.exists() and not args.force:
print(f"Error: Timer file already exists: {timer_path}", file=sys.stderr)
print("Use --force to overwrite", file=sys.stderr)
sys.exit(1)
# Write files
service_path.write_text(service_content)
timer_path.write_text(timer_content)
print(f"Created service: {service_path}")
print(f"Created timer: {timer_path}")
# Reload systemd daemon
print("\nReloading systemd daemon...")
run_systemctl(["daemon-reload"])
# Enable and start timer if requested
if not args.no_enable:
print(f"Enabling {name}.timer...")
run_systemctl(["enable", f"{name}.timer"])
if not args.no_start:
print(f"Starting {name}.timer...")
run_systemctl(["start", f"{name}.timer"])
print(f"\n✓ Timer '{name}' created and started successfully!")
# Warn about environment variables if not explicitly set in service
if not environment and not args.env_file:
print(
"\n⚠️ Note: Systemd user units don't inherit shell environment variables."
)
print(" If your command needs environment variables, pass them with:")
print(f" schedule add {name} --command '...' -e VAR=value")
print("\nUseful commands:")
print(f" Status: schedule status {name}")
print(f" Logs: schedule logs {name} --follow")
print(f" Disable: schedule disable {name}")
print(f" Remove: schedule remove {name}")
def remove_timer(args: argparse.Namespace) -> None:
"""Remove a systemd timer and service."""
name = args.name
systemd_dir = get_systemd_user_dir()
service_path = systemd_dir / f"{name}.service"
timer_path = systemd_dir / f"{name}.timer"
# Check if files exist
if not service_path.exists() and not timer_path.exists():
print(f"Error: Timer '{name}' not found", file=sys.stderr)
sys.exit(1)
# Stop and disable timer
print(f"Stopping {name}.timer...")
run_systemctl(["stop", f"{name}.timer"], check=False)
print(f"Disabling {name}.timer...")
run_systemctl(["disable", f"{name}.timer"], check=False)
# Remove files
if timer_path.exists():
timer_path.unlink()
print(f"Removed: {timer_path}")
if service_path.exists():
service_path.unlink()
print(f"Removed: {service_path}")
# Reload systemd daemon
print("\nReloading systemd daemon...")
run_systemctl(["daemon-reload"])
# Clear logs if requested
if not args.keep_logs:
print(f"Clearing logs for {name}...")
subprocess.run(
["journalctl", "--user", "--vacuum-time=1s", "-u", name],
check=False,
capture_output=True,
)
print(f"\n✓ Timer '{name}' removed successfully!")
def status_timer(args: argparse.Namespace) -> None:
"""Show status of a timer and its service."""
name = args.name
print("=== Timer Status ===")
result = run_systemctl(["status", f"{name}.timer"], check=False)
print(result.stdout)
print("\n=== Service Status ===")
result = run_systemctl(["status", f"{name}.service"], check=False)
print(result.stdout)
print("\n=== Next Scheduled Run ===")
result = run_systemctl(["list-timers", f"{name}.timer"], check=False)
print(result.stdout)
def logs_timer(args: argparse.Namespace) -> None:
"""Show logs for a service."""
name = args.name
cmd = ["journalctl", "--user", "-u", name]
if args.follow:
cmd.append("-f")
if args.lines:
cmd.extend(["-n", str(args.lines)])
if args.since:
cmd.extend(["--since", args.since])
# Run journalctl directly (don't capture output)
subprocess.run(cmd)
def enable_timer(args: argparse.Namespace) -> None:
"""Enable a timer."""
name = args.name
print(f"Enabling {name}.timer...")
run_systemctl(["enable", f"{name}.timer"])
print(f"✓ Timer '{name}' enabled")
def disable_timer(args: argparse.Namespace) -> None:
"""Disable a timer."""
name = args.name
print(f"Disabling {name}.timer...")
run_systemctl(["disable", f"{name}.timer"])
print(f"✓ Timer '{name}' disabled")
def start_timer(args: argparse.Namespace) -> None:
"""Start a timer."""
name = args.name
print(f"Starting {name}.timer...")
run_systemctl(["start", f"{name}.timer"])
print(f"✓ Timer '{name}' started")
def stop_timer(args: argparse.Namespace) -> None:
"""Stop a timer."""
name = args.name
print(f"Stopping {name}.timer...")
run_systemctl(["stop", f"{name}.timer"])
print(f"✓ Timer '{name}' stopped")
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Manage systemd user timers and services",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
subparsers = parser.add_subparsers(dest="subcommand", help="Command to execute")
# List command
list_parser = subparsers.add_parser("list", help="List all user timers")
list_parser.add_argument(
"--all", action="store_true", help="Show all timers including inactive"
)
# Add command
add_parser = subparsers.add_parser("add", help="Add a new timer")
add_parser.add_argument("name", help="Name of the timer/service")
add_parser.add_argument("--command", required=True, help="Command to execute")
add_parser.add_argument(
"--schedule",
required=True,
help="Schedule (e.g., '5min', 'hourly', 'daily', '1h')",
)
add_parser.add_argument("--description", help="Description of the timer/service")
add_parser.add_argument(
"--env-file", help="Path to environment file (e.g., ~/.config/app/env)"
)
add_parser.add_argument(
"--environment",
"-e",
action="append",
help="Environment variable (KEY=VALUE)",
)
add_parser.add_argument(
"--condition-command",
help="Command that must succeed before running (e.g., 'pgrep myapp')",
)
add_parser.add_argument(
"--working-directory", help="Working directory for the command"
)
add_parser.add_argument(
"--on-boot-sec", default="1min", help="Delay after boot (default: 1min)"
)
add_parser.add_argument(
"--on-calendar",
help="Calendar-based schedule (e.g., '*-*-* 02:00:00' for 2 AM daily)",
)
add_parser.add_argument(
"--persistent",
action="store_true",
help="Run missed timers if system was offline",
)
add_parser.add_argument(
"--force", action="store_true", help="Overwrite existing timer/service"
)
add_parser.add_argument(
"--no-enable", action="store_true", help="Don't enable the timer"
)
add_parser.add_argument(
"--no-start", action="store_true", help="Don't start the timer"
)
# Remove command
remove_parser = subparsers.add_parser("remove", help="Remove a timer")
remove_parser.add_argument("name", help="Name of the timer/service")
remove_parser.add_argument(
"--keep-logs", action="store_true", help="Keep service logs"
)
# Status command
status_parser = subparsers.add_parser("status", help="Show timer status")
status_parser.add_argument("name", help="Name of the timer/service")
# Logs command
logs_parser = subparsers.add_parser("logs", help="Show service logs")
logs_parser.add_argument("name", help="Name of the timer/service")
logs_parser.add_argument(
"--follow", "-f", action="store_true", help="Follow logs in real-time"
)
logs_parser.add_argument("--lines", "-n", type=int, help="Number of lines to show")
logs_parser.add_argument("--since", help="Show logs since (e.g., 'today', '1h')")
# Enable command
enable_parser = subparsers.add_parser("enable", help="Enable a timer")
enable_parser.add_argument("name", help="Name of the timer")
# Disable command
disable_parser = subparsers.add_parser("disable", help="Disable a timer")
disable_parser.add_argument("name", help="Name of the timer")
# Start command
start_parser = subparsers.add_parser("start", help="Start a timer")
start_parser.add_argument("name", help="Name of the timer")
# Stop command
stop_parser = subparsers.add_parser("stop", help="Stop a timer")
stop_parser.add_argument("name", help="Name of the timer")
args = parser.parse_args()
if not args.subcommand:
parser.print_help()
sys.exit(1)
# Route to appropriate function
commands = {
"list": list_timers,
"add": add_timer,
"remove": remove_timer,
"status": status_timer,
"logs": logs_timer,
"enable": enable_timer,
"disable": disable_timer,
"start": start_timer,
"stop": stop_timer,
}
commands[args.subcommand](args)
if __name__ == "__main__":
main()