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

View File

@@ -0,0 +1,121 @@
"""castle create - scaffold a new project from templates."""
from __future__ import annotations
import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ComponentManifest,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
InstallSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
RunPythonUvTool,
SystemdSpec,
)
from castle_cli.templates.scaffold import scaffold_project
def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set()
for manifest in config.components.values():
if manifest.expose and manifest.expose.http:
used_ports.add(manifest.expose.http.internal.port)
# Also reserve gateway port
used_ports.add(config.gateway.port)
port = 9001
while port in used_ports:
port += 1
return port
def run_create(args: argparse.Namespace) -> int:
"""Create a new project."""
config = load_config()
name = args.name
proj_type = args.type
if name in config.components:
print(f"Error: component '{name}' already exists in castle.yaml")
return 1
project_dir = config.root / name
if project_dir.exists():
print(f"Error: directory '{name}' already exists")
return 1
# Determine port for services
port = args.port
if proj_type == "service" and port is None:
port = next_available_port(config)
# Package name: convert kebab-case to snake_case
package_name = name.replace("-", "_")
# Scaffold the project files
scaffold_project(
project_dir=project_dir,
name=name,
package_name=package_name,
proj_type=proj_type,
description=args.description or f"A castle {proj_type}",
port=port,
)
# Build manifest entry
env_prefix = package_name.upper()
if proj_type == "service":
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
run=RunPythonUvTool(
runner="python_uv_tool",
tool=name,
cwd=name,
env={f"{env_prefix}_DATA_DIR": f"/data/castle/{name}"},
),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=port),
health_path="/health",
)
),
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()),
)
elif proj_type == "tool":
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
# library or other
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
)
config.components[name] = manifest
save_config(config)
print(f"Created {proj_type} '{name}' at {project_dir}")
if port:
print(f" Port: {port}")
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd {name}")
print(" uv sync")
if proj_type == "service":
print(f" uv run {name} # starts on port {port}")
print(f" castle test {name}")
return 0

View File

@@ -0,0 +1,118 @@
"""castle test / castle lint - run dev commands across projects."""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
from castle_cli.config import CastleConfig, load_config
def _get_project_dir(config: CastleConfig, project_name: str) -> Path:
"""Get the directory for a component."""
if project_name not in config.components:
raise ValueError(f"Unknown component: {project_name}")
manifest = config.components[project_name]
cwd = manifest.run.cwd if manifest.run else None
working_dir = cwd or project_name
return config.root / working_dir
def _has_pyproject(project_dir: Path) -> bool:
"""Check if a project directory has a pyproject.toml."""
return (project_dir / "pyproject.toml").exists()
def _run_in_project(
project_dir: Path, cmd: list[str], label: str
) -> bool:
"""Run a command in a project directory. Returns True on success."""
if not _has_pyproject(project_dir):
return True # Skip projects without pyproject.toml
print(f"\n{'' * 40}")
print(f" {label}: {project_dir.name}")
print(f"{'' * 40}")
result = subprocess.run(cmd, cwd=project_dir)
return result.returncode == 0
def run_test(args: argparse.Namespace) -> int:
"""Run tests for one or all projects."""
config = load_config()
if args.project:
project_dir = _get_project_dir(config, args.project)
tests_dir = project_dir / "tests"
if not tests_dir.exists():
print(f"No tests directory found for {args.project}")
return 1
success = _run_in_project(
project_dir,
["uv", "run", "pytest", "tests/", "-v"],
"Testing",
)
return 0 if success else 1
# Run all
all_passed = True
for name, manifest in config.components.items():
cwd = manifest.run.cwd if manifest.run else None
working_dir = cwd or name
project_dir = config.root / working_dir
tests_dir = project_dir / "tests"
if not tests_dir.exists():
continue
if not _has_pyproject(project_dir):
continue
success = _run_in_project(
project_dir,
["uv", "run", "pytest", "tests/", "-v"],
"Testing",
)
if not success:
all_passed = False
if all_passed:
print("\nAll tests passed.")
else:
print("\nSome tests failed.")
return 0 if all_passed else 1
def run_lint(args: argparse.Namespace) -> int:
"""Run linter for one or all projects."""
config = load_config()
if args.project:
project_dir = _get_project_dir(config, args.project)
success = _run_in_project(
project_dir,
["uv", "run", "ruff", "check", "."],
"Linting",
)
return 0 if success else 1
# Run all
all_passed = True
for name, manifest in config.components.items():
cwd = manifest.run.cwd if manifest.run else None
working_dir = cwd or name
project_dir = config.root / working_dir
if not _has_pyproject(project_dir):
continue
success = _run_in_project(
project_dir,
["uv", "run", "ruff", "check", "."],
"Linting",
)
if not success:
all_passed = False
if all_passed:
print("\nAll lint checks passed.")
else:
print("\nSome lint checks failed.")
return 0 if all_passed else 1

View File

@@ -0,0 +1,186 @@
"""castle gateway - manage the Caddy reverse proxy gateway."""
from __future__ import annotations
import argparse
import shutil
import subprocess
from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config
def _find_dashboard_dist(config: CastleConfig) -> str | None:
"""Find the dashboard dist/ directory if it exists."""
dist = config.root / "dashboard" / "dist"
if dist.exists() and (dist / "index.html").exists():
return str(dist)
return None
def _generate_caddyfile(config: CastleConfig) -> str:
"""Generate Caddyfile content from castle config."""
lines = [f":{config.gateway.port} {{"]
# Reverse proxy for each component with proxy.caddy and expose.http
for name, manifest in config.components.items():
if not (manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable):
continue
if not (manifest.expose and manifest.expose.http):
continue
caddy = manifest.proxy.caddy
http = manifest.expose.http
path_prefix = caddy.path_prefix or f"/{name}"
port = http.internal.port
host = http.internal.host or "localhost"
lines.append(f" handle_path {path_prefix}/* {{")
lines.append(f" reverse_proxy {host}:{port}")
lines.append(" }")
lines.append("")
# Dashboard SPA at root (must come after more-specific handle_path rules)
dashboard_dist = _find_dashboard_dist(config)
if dashboard_dist:
lines.append(" handle {")
lines.append(f" root * {dashboard_dist}")
lines.append(" try_files {path} /index.html")
lines.append(" file_server")
lines.append(" }")
else:
# Fallback: serve from generated directory
fallback = GENERATED_DIR / "dashboard"
lines.append(" handle / {")
lines.append(f" root * {fallback}")
lines.append(" file_server")
lines.append(" }")
lines.append("}")
return "\n".join(lines)
def _write_generated_files(config: CastleConfig) -> None:
"""Write generated Caddyfile."""
ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(_generate_caddyfile(config))
print(f" Generated {caddyfile_path}")
dashboard_dist = _find_dashboard_dist(config)
if dashboard_dist:
print(f" Dashboard: {dashboard_dist}")
else:
print(" Dashboard: dist/ not found, using fallback")
def run_gateway(args: argparse.Namespace) -> int:
"""Manage the Caddy gateway."""
if not args.gateway_command:
print("Usage: castle gateway {start|stop|reload|status}")
return 1
config = load_config()
if args.gateway_command in ("start", "reload") and getattr(args, "dry_run", False):
return _gateway_dry_run(config)
if args.gateway_command == "start":
return _gateway_start(config)
elif args.gateway_command == "stop":
return _gateway_stop()
elif args.gateway_command == "reload":
return _gateway_reload(config)
elif args.gateway_command == "status":
return _gateway_status()
return 1
def _gateway_dry_run(config: CastleConfig) -> int:
"""Print generated Caddyfile and gateway unit without applying."""
from castle_cli.commands.service import _generate_gateway_unit
print("# Caddyfile")
print(_generate_caddyfile(config))
print()
print("# castle-gateway.service")
print(_generate_gateway_unit(config))
return 0
def _gateway_start(config: CastleConfig) -> int:
"""Generate config and start Caddy."""
if not shutil.which("caddy"):
print("Error: caddy is not installed.")
print("Install with: sudo apt install caddy")
return 1
print("Generating gateway configuration...")
_write_generated_files(config)
caddyfile = GENERATED_DIR / "Caddyfile"
print(f"\nStarting Caddy on port {config.gateway.port}...")
result = subprocess.run(
["caddy", "start", "--config", str(caddyfile), "--adapter", "caddyfile"],
)
if result.returncode == 0:
print(f"Gateway running at http://localhost:{config.gateway.port}")
else:
print("Failed to start gateway.")
return result.returncode
def _gateway_stop() -> int:
"""Stop Caddy."""
if not shutil.which("caddy"):
print("Error: caddy is not installed.")
return 1
result = subprocess.run(["caddy", "stop"])
if result.returncode == 0:
print("Gateway stopped.")
return result.returncode
def _gateway_reload(config: CastleConfig) -> int:
"""Regenerate config and reload Caddy."""
print("Regenerating gateway configuration...")
_write_generated_files(config)
caddyfile = GENERATED_DIR / "Caddyfile"
result = subprocess.run(
["caddy", "reload", "--config", str(caddyfile), "--adapter", "caddyfile"],
)
if result.returncode == 0:
print("Gateway reloaded.")
else:
print("Failed to reload gateway. Is it running?")
return result.returncode
def _gateway_status() -> int:
"""Show gateway status."""
if not shutil.which("caddy"):
print("Gateway: not installed")
return 1
result = subprocess.run(
["pgrep", "-x", "caddy"],
capture_output=True,
)
if result.returncode == 0:
print("Gateway: running")
caddyfile = GENERATED_DIR / "Caddyfile"
if caddyfile.exists():
print(f" Config: {caddyfile}")
else:
print("Gateway: stopped")
return 0

View File

@@ -0,0 +1,116 @@
"""castle info - show detailed component information."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from castle_cli.config import load_config
# Terminal colors
BOLD = "\033[1m"
RESET = "\033[0m"
CYAN = "\033[96m"
DIM = "\033[2m"
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a component."""
config = load_config()
name = args.project
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
if getattr(args, "json", False):
data = manifest.model_dump(exclude_none=True)
# Include CLAUDE.md content if it exists
cwd = manifest.run.cwd if manifest.run else None
claude_md = _find_claude_md(config.root, cwd or name)
if claude_md:
data["claude_md"] = claude_md
print(json.dumps(data, indent=2))
return 0
# Human-readable output
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
print(f" {BOLD}roles{RESET}: {', '.join(r.value for r in manifest.roles)}")
if manifest.description:
print(f" {BOLD}description{RESET}: {manifest.description}")
# Run spec
if manifest.run:
print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
if manifest.run.cwd:
print(f" {BOLD}cwd{RESET}: {manifest.run.cwd}")
if hasattr(manifest.run, "tool"):
print(f" {BOLD}tool{RESET}: {manifest.run.tool}")
elif hasattr(manifest.run, "argv"):
print(f" {BOLD}argv{RESET}: {manifest.run.argv}")
elif hasattr(manifest.run, "image"):
print(f" {BOLD}image{RESET}: {manifest.run.image}")
if manifest.run.env:
print(f" {BOLD}env{RESET}:")
for key, val in manifest.run.env.items():
print(f" {key}: {val}")
# Expose
if manifest.expose and manifest.expose.http:
http = manifest.expose.http
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
# Proxy
if manifest.proxy and manifest.proxy.caddy:
caddy = manifest.proxy.caddy
if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
# Manage
if manifest.manage and manifest.manage.systemd:
sd = manifest.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
# Install
if manifest.install and manifest.install.path:
pi = manifest.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
# Tags
if manifest.tags:
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
# Capabilities
if manifest.provides:
print(f" {BOLD}provides{RESET}:")
for cap in manifest.provides:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
if manifest.consumes:
print(f" {BOLD}consumes{RESET}:")
for cap in manifest.consumes:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
# Show CLAUDE.md if it exists
cwd = manifest.run.cwd if manifest.run else None
claude_md = _find_claude_md(config.root, cwd or name)
if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
print(f"{DIM}{claude_md}{RESET}")
print()
return 0
def _find_claude_md(root: Path, working_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists."""
claude_path = root / working_dir / "CLAUDE.md"
if claude_path.exists():
return claude_path.read_text()
return None

View File

@@ -0,0 +1,81 @@
"""castle list - show all registered components."""
from __future__ import annotations
import argparse
import json
from castle_cli.config import load_config
from castle_cli.manifest import Role
# Terminal colors
BOLD = "\033[1m"
RESET = "\033[0m"
DIM = "\033[2m"
ROLE_COLORS: dict[str, str] = {
Role.SERVICE: "\033[92m", # green
Role.TOOL: "\033[96m", # cyan
Role.WORKER: "\033[94m", # blue
Role.JOB: "\033[95m", # magenta
Role.FRONTEND: "\033[93m", # yellow
Role.REMOTE: "\033[90m", # dim
Role.CONTAINERIZED: "\033[33m", # orange
}
def run_list(args: argparse.Namespace) -> int:
"""List all components."""
config = load_config()
components = config.components
filter_role = getattr(args, "role", None)
if filter_role:
components = {
k: v for k, v in components.items() if filter_role in v.roles
}
if getattr(args, "json", False):
output = []
for name, manifest in components.items():
entry: dict = {
"name": name,
"roles": [r.value for r in manifest.roles],
}
if manifest.description:
entry["description"] = manifest.description
if manifest.expose and manifest.expose.http:
entry["port"] = manifest.expose.http.internal.port
output.append(entry)
print(json.dumps(output, indent=2))
return 0
if not components:
print("No components found.")
return 0
# Group by primary role (first in sorted list)
by_role: dict[str, list[tuple[str, object]]] = {}
for name, manifest in components.items():
primary_role = manifest.roles[0].value if manifest.roles else "other"
by_role.setdefault(primary_role, []).append((name, manifest))
# Display order
role_order = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
for role_name in role_order:
items = by_role.get(role_name, [])
if not items:
continue
color = ROLE_COLORS.get(role_name, "")
print(f"\n{BOLD}{color}{role_name}s{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, manifest in items:
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f" :{manifest.expose.http.internal.port}"
desc = f" {DIM}{manifest.description}{RESET}" if manifest.description else ""
print(f" {BOLD}{name}{RESET}{port_str}{desc}")
print()
return 0

View File

@@ -0,0 +1,69 @@
"""castle logs - view component logs."""
from __future__ import annotations
import argparse
import subprocess
from castle_cli.commands.service import UNIT_PREFIX
from castle_cli.config import load_config
def run_logs(args: argparse.Namespace) -> int:
"""View logs for a component."""
config = load_config()
name = args.name
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
# Container logs
if manifest.run and manifest.run.runner == "container":
return _container_logs(name, args)
# Systemd logs (default for managed services)
if manifest.manage and manifest.manage.systemd:
return _systemd_logs(name, args)
print(f"Error: '{name}' has no log source (not systemd-managed or containerized)")
return 1
def _systemd_logs(name: str, args: argparse.Namespace) -> int:
"""Show journalctl logs for a systemd service."""
unit_name = f"{UNIT_PREFIX}{name}.service"
cmd = ["journalctl", "--user", "-u", unit_name]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["-n", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
result = subprocess.run(cmd)
return result.returncode
def _container_logs(name: str, args: argparse.Namespace) -> int:
"""Show container logs."""
import shutil
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
container_name = f"castle-{name}"
cmd = [runtime, "logs"]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["--tail", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
cmd.append(container_name)
result = subprocess.run(cmd)
return result.returncode

View File

@@ -0,0 +1,96 @@
"""castle run - run a component in the foreground."""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from castle_cli.config import load_config, resolve_env_vars
def run_run(args: argparse.Namespace) -> int:
"""Run a component in the foreground (dev mode)."""
config = load_config()
name = args.name
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
run = manifest.run
if run is None:
print(f"Error: component '{name}' has no run spec")
return 1
# Build command
extra_args = getattr(args, "extra", []) or []
cmd = _build_command(run, extra_args)
if cmd is None:
print(f"Error: unsupported runner '{run.runner}' for foreground execution")
return 1
# Working directory
cwd = config.root / (run.cwd or name)
if not cwd.exists():
print(f"Error: working directory '{cwd}' does not exist")
return 1
# Merge environment
env = dict(os.environ)
resolved = resolve_env_vars(run.env, manifest)
env.update(resolved)
# Run in foreground
result = subprocess.run(cmd, cwd=cwd, env=env)
return result.returncode
def _build_command(run: object, extra_args: list[str]) -> list[str] | None:
"""Build command list from RunSpec."""
match run.runner:
case "python_uv_tool":
uv = shutil.which("uv") or "uv"
cmd = [uv, "run", run.tool]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "python_module":
python = run.python or sys.executable
cmd = [python, "-m", run.module]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "command":
cmd = list(run.argv)
cmd.extend(extra_args)
return cmd
case "container":
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
cmd = [runtime, "run", "--rm", "-it"]
for cp, hp in run.ports.items():
cmd.extend(["-p", f"{hp}:{cp}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
if run.workdir:
cmd.extend(["-w", run.workdir])
cmd.append(run.image)
if run.command:
cmd.extend(run.command)
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "node":
pm = run.package_manager
cmd = [pm, "run", run.script]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case _:
return None

View File

@@ -0,0 +1,517 @@
"""castle service / castle services - manage systemd service units."""
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_cli.config import (
GENERATED_DIR,
CastleConfig,
ensure_dirs,
load_config,
resolve_env_vars,
)
from castle_cli.manifest import ComponentManifest, RestartPolicy
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-"
def _unit_name(service_name: str) -> str:
"""Get the systemd unit name for a service."""
return f"{UNIT_PREFIX}{service_name}.service"
def _timer_name(service_name: str) -> str:
"""Get the systemd timer name for a scheduled service."""
return f"{UNIT_PREFIX}{service_name}.timer"
def _get_schedule_trigger(manifest: ComponentManifest) -> object | None:
"""Return the schedule trigger if one exists, else None."""
for t in manifest.triggers:
if getattr(t, "type", None) == "schedule":
return t
return None
def _cron_to_oncalendar(cron: str) -> str:
"""Best-effort conversion of cron expression to systemd OnCalendar.
Handles common patterns; falls back to using OnUnitActiveSec for the rest.
"""
parts = cron.strip().split()
if len(parts) != 5:
return ""
minute, hour, dom, month, dow = parts
# */N minutes → run every N minutes
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
return "" # Use OnUnitActiveSec instead
# Specific time daily: "0 2 * * *" → "*-*-* 02:00:00"
if dom == "*" and month == "*" and dow == "*":
h = hour.zfill(2) if hour != "*" else "*"
m = minute.zfill(2) if minute != "*" else "*"
return f"*-*-* {h}:{m}:00"
return ""
def _cron_to_interval_sec(cron: str) -> int | None:
"""Extract interval seconds from */N cron patterns."""
parts = cron.strip().split()
if len(parts) != 5:
return None
minute, hour, dom, month, dow = parts
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
try:
return int(minute[2:]) * 60
except ValueError:
return None
return None
def _manifest_to_exec_start(manifest: ComponentManifest, root: Path) -> str:
"""Convert a manifest's RunSpec to a systemd ExecStart command."""
run = manifest.run
if run is None:
raise ValueError(f"Component '{manifest.id}' has no run spec")
match run.runner:
case "python_uv_tool":
uv_path = shutil.which("uv") or "uv"
args_str = " ".join(run.args) if run.args else ""
cmd = f"{uv_path} run {run.tool}"
if args_str:
cmd += f" {args_str}"
return cmd
case "python_module":
python = run.python or shutil.which("python3") or "python3"
args_str = " ".join(run.args) if run.args else ""
cmd = f"{python} -m {run.module}"
if args_str:
cmd += f" {args_str}"
return cmd
case "command":
argv = list(run.argv)
resolved = shutil.which(argv[0])
if resolved:
argv[0] = resolved
return " ".join(argv)
case "container":
return _build_podman_command(manifest)
case "node":
pm = run.package_manager
cmd = f"{pm} run {run.script}"
if run.args:
cmd += " " + " ".join(run.args)
return cmd
case _:
raise ValueError(f"Unsupported runner '{run.runner}' for systemd unit")
def _build_podman_command(manifest: ComponentManifest) -> str:
"""Build a podman/docker run command from a container RunSpec."""
run = manifest.run
podman = shutil.which("podman") or shutil.which("docker") or "podman"
parts = [podman, "run", "--rm", f"--name=castle-{manifest.id}"]
for container_port, host_port in run.ports.items():
parts.append(f"-p {host_port}:{container_port}")
for vol in run.volumes:
parts.append(f"-v {vol}")
for key, val in run.env.items():
parts.append(f"-e {key}={val}")
if run.workdir:
parts.append(f"-w {run.workdir}")
parts.append(run.image)
if run.command:
parts.extend(run.command)
if run.args:
parts.extend(run.args)
return " ".join(parts)
def _generate_unit(config: CastleConfig, name: str, manifest: ComponentManifest) -> str:
"""Generate a systemd user unit file for a component."""
run = manifest.run
if run is None:
raise ValueError(f"Component '{name}' has no run spec")
working_dir = config.root / (run.cwd or name)
exec_start = _manifest_to_exec_start(manifest, config.root)
resolved_env = resolve_env_vars(run.env, manifest)
env_lines = ""
for key, value in resolved_env.items():
env_lines += f"Environment={key}={value}\n"
# Add PATH so tools are findable
env_lines += f'Environment="PATH={Path.home() / ".local/bin"}:/usr/local/bin:/usr/bin:/bin"\n'
sd = None
if manifest.manage and manifest.manage.systemd:
sd = manifest.manage.systemd
description = (sd and sd.description) or manifest.description or name
after = " ".join(sd.after) if sd and sd.after else "network.target"
wanted_by = " ".join(sd.wanted_by) if sd else "default.target"
is_scheduled = _get_schedule_trigger(manifest) is not None
if is_scheduled:
# Oneshot service for timer-driven jobs
unit = f"""[Unit]
Description=Castle: {description}
After={after}
[Service]
Type=oneshot
WorkingDirectory={working_dir}
ExecStart={exec_start}
{env_lines}"""
else:
restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value
restart_sec = sd.restart_sec if sd else 5
unit = f"""[Unit]
Description=Castle: {description}
After={after}
[Service]
Type=simple
WorkingDirectory={working_dir}
ExecStart={exec_start}
{env_lines}Restart={restart}
RestartSec={restart_sec}
"""
if sd and sd.no_new_privileges:
unit += "NoNewPrivileges=true\n"
unit += f"""
[Install]
WantedBy={wanted_by}
"""
return unit
def _generate_timer(name: str, manifest: ComponentManifest) -> str | None:
"""Generate a systemd timer unit if the component has a schedule trigger."""
trigger = _get_schedule_trigger(manifest)
if trigger is None:
return None
description = manifest.description or name
# Try to convert cron to OnCalendar, fall back to OnUnitActiveSec
on_calendar = _cron_to_oncalendar(trigger.cron)
interval_sec = _cron_to_interval_sec(trigger.cron)
timer_lines = ""
if on_calendar:
timer_lines = f"OnCalendar={on_calendar}\n"
elif interval_sec:
timer_lines = f"OnBootSec=60\nOnUnitActiveSec={interval_sec}s\n"
else:
timer_lines = "OnBootSec=60\nOnUnitActiveSec=300\n"
return f"""[Unit]
Description=Castle timer: {description}
[Timer]
{timer_lines}Persistent=false
[Install]
WantedBy=timers.target
"""
def _generate_gateway_unit(config: CastleConfig) -> str:
"""Generate a systemd unit for the Caddy gateway."""
caddy_path = shutil.which("caddy") or "caddy"
caddyfile = GENERATED_DIR / "Caddyfile"
return f"""[Unit]
Description=Castle Gateway (Caddy)
After=network.target
[Service]
Type=simple
ExecStart={caddy_path} run --config {caddyfile} --adapter caddyfile
ExecReload={caddy_path} reload --config {caddyfile} --adapter caddyfile
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
"""
def _install_unit(unit_name: str, content: str) -> None:
"""Write a systemd unit file."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
unit_path = SYSTEMD_USER_DIR / unit_name
unit_path.write_text(content)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
def _remove_unit(unit_name: str) -> None:
"""Remove a systemd unit file."""
unit_path = SYSTEMD_USER_DIR / unit_name
if unit_path.exists():
unit_path.unlink()
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
def run_service(args: argparse.Namespace) -> int:
"""Manage individual services."""
if not args.service_command:
print("Usage: castle service {enable|disable|status}")
return 1
config = load_config()
if args.service_command == "enable":
if getattr(args, "dry_run", False):
return _service_dry_run(config, args.name)
return _service_enable(config, args.name)
elif args.service_command == "disable":
return _service_disable(args.name)
elif args.service_command == "status":
return _service_status(config)
return 1
def run_services(args: argparse.Namespace) -> int:
"""Manage all services together."""
if not args.services_command:
print("Usage: castle services {start|stop}")
return 1
config = load_config()
if args.services_command == "start":
return _services_start(config)
elif args.services_command == "stop":
return _services_stop(config)
return 1
def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable and start a single service (or timer for scheduled jobs)."""
managed = config.managed
if name not in managed:
print(f"Error: '{name}' is not a managed service")
return 1
manifest = managed[name]
if not manifest.run:
print(f"Error: '{name}' has no run spec defined")
return 1
ensure_dirs()
# Generate and install the service unit
svc_unit = _unit_name(name)
svc_content = _generate_unit(config, name, manifest)
_install_unit(svc_unit, svc_content)
# Check for timer
timer_content = _generate_timer(name, manifest)
if timer_content:
timer_unit = _timer_name(name)
_install_unit(timer_unit, timer_content)
print(f"Enabling {name} (scheduled)...")
subprocess.run(["systemctl", "--user", "enable", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "start", timer_unit], check=False)
result = subprocess.run(
["systemctl", "--user", "is-active", timer_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
print(f" {name}: timer active")
else:
print(f" {name}: timer {status}")
else:
print(f"Enabling {name}...")
subprocess.run(["systemctl", "--user", "enable", svc_unit], check=False)
subprocess.run(["systemctl", "--user", "start", svc_unit], check=False)
result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f" (port {manifest.expose.http.internal.port})"
if status == "active":
print(f" {name}: running{port_str}")
else:
print(f" {name}: {status}")
print(f" Check logs: journalctl --user -u {svc_unit}")
return 0
def _service_disable(name: str) -> int:
"""Stop and disable a service (and timer if present)."""
svc_unit = _unit_name(name)
timer_unit = _timer_name(name)
print(f"Disabling {name}...")
# Stop and disable timer if exists
timer_path = SYSTEMD_USER_DIR / timer_unit
if timer_path.exists():
subprocess.run(["systemctl", "--user", "stop", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", timer_unit], check=False)
_remove_unit(timer_unit)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", svc_unit], check=False)
_remove_unit(svc_unit)
print(f" {name}: disabled")
return 0
def _service_status(config: CastleConfig) -> int:
"""Show status of all managed services."""
print("\nCastle Services")
print("=" * 50)
for name, manifest in config.managed.items():
is_scheduled = _get_schedule_trigger(manifest) is not None
if is_scheduled:
timer_unit = _timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", timer_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
else:
unit_name = _unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", unit_name],
capture_output=True, text=True,
)
status = result.stdout.strip()
if status == "active":
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f":{manifest.expose.http.internal.port}"
print(f" {color}{status:10s}{reset} {name}{port_str}")
# Gateway status
gw_unit = f"{UNIT_PREFIX}gateway.service"
result = subprocess.run(
["systemctl", "--user", "is-active", gw_unit],
capture_output=True, text=True,
)
gw_status = result.stdout.strip()
color = "\033[92m" if gw_status == "active" else "\033[90m"
reset = "\033[0m"
print(f" {color}{gw_status:10s}{reset} gateway :{config.gateway.port}")
print()
return 0
def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing."""
managed = config.managed
if name not in managed:
print(f"Error: '{name}' is not a managed service")
return 1
manifest = managed[name]
if not manifest.run:
print(f"Error: '{name}' has no run spec defined")
return 1
svc_unit = _unit_name(name)
svc_content = _generate_unit(config, name, manifest)
print(f"# {svc_unit}")
print(svc_content)
timer_content = _generate_timer(name, manifest)
if timer_content:
print(f"# {_timer_name(name)}")
print(timer_content)
return 0
def _services_start(config: CastleConfig) -> int:
"""Start all managed services and gateway."""
ensure_dirs()
from castle_cli.commands.gateway import _write_generated_files
print("Generating gateway configuration...")
_write_generated_files(config)
if shutil.which("caddy"):
gw_unit_name = f"{UNIT_PREFIX}gateway.service"
gw_content = _generate_gateway_unit(config)
_install_unit(gw_unit_name, gw_content)
subprocess.run(["systemctl", "--user", "enable", gw_unit_name], check=False)
subprocess.run(["systemctl", "--user", "start", gw_unit_name], check=False)
print(f"Gateway: started on port {config.gateway.port}")
else:
print("Warning: caddy not installed, skipping gateway")
for name, manifest in config.managed.items():
if not manifest.run:
continue
_service_enable(config, name)
print(f"\nDashboard: http://localhost:{config.gateway.port}")
return 0
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and gateway."""
for name, manifest in config.managed.items():
is_scheduled = _get_schedule_trigger(manifest) is not None
if is_scheduled:
timer_unit = _timer_name(name)
subprocess.run(["systemctl", "--user", "stop", timer_unit], check=False)
unit_name = _unit_name(name)
subprocess.run(["systemctl", "--user", "stop", unit_name], check=False)
print(f" {name}: stopped")
gw_unit = f"{UNIT_PREFIX}gateway.service"
subprocess.run(["systemctl", "--user", "stop", gw_unit], check=False)
print(" gateway: stopped")
return 0

View File

@@ -0,0 +1,103 @@
"""castle sync - sync submodules and install dependencies."""
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_cli.config import ensure_dirs, load_config
from castle_cli.manifest import ToolType
def run_sync(args: argparse.Namespace) -> int:
"""Sync submodules and install dependencies in all projects."""
config = load_config()
ensure_dirs()
# Update git submodules
print("Updating git submodules...")
result = subprocess.run(
["git", "submodule", "update", "--init", "--recursive"],
cwd=config.root,
)
if result.returncode != 0:
print("Warning: git submodule update failed (may not be a git repo)")
# Run uv sync in each project that has a pyproject.toml
all_ok = True
synced_dirs: set[Path] = set()
for name, manifest in config.components.items():
cwd = manifest.run.cwd if manifest.run else None
working_dir = cwd or name
project_dir = config.root / working_dir
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists() or project_dir in synced_dirs:
continue
print(f"\nSyncing {name}...")
result = subprocess.run(["uv", "sync"], cwd=project_dir)
if result.returncode != 0:
print(f" Warning: uv sync failed for {name}")
all_ok = False
else:
print(" OK")
synced_dirs.add(project_dir)
# Install tools by type
uv_path = shutil.which("uv") or "uv"
installed_dirs: set[Path] = set()
for name, manifest in config.components.items():
if not manifest.tool:
continue
if manifest.tool.tool_type == ToolType.PYTHON_STANDALONE:
source = manifest.tool.source
if not source:
continue
project_dir = config.root / source
if not (project_dir / "pyproject.toml").exists():
continue
if project_dir in installed_dirs:
continue
print(f"\nInstalling {name}...")
result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(project_dir),
"--force"],
capture_output=True, text=True,
)
if result.returncode != 0:
if "already installed" in result.stderr.lower():
print(f" {name}: already installed")
else:
print(f" Warning: {result.stderr.strip()}")
all_ok = False
else:
print(f" {name}: OK")
installed_dirs.add(project_dir)
elif manifest.tool.tool_type == ToolType.SCRIPT:
# Verify script tools are accessible
alias = name
if manifest.install and manifest.install.path and manifest.install.path.alias:
alias = manifest.install.path.alias
if not shutil.which(alias):
source = manifest.tool.source
if source:
script_path = config.root / source
if script_path.exists():
link = Path.home() / ".local" / "bin" / alias
link.parent.mkdir(parents=True, exist_ok=True)
if not link.exists():
link.symlink_to(script_path)
print(f"\n Linked {alias}{script_path}")
if all_ok:
print("\nAll projects synced.")
else:
print("\nSync completed with warnings.")
return 0

View File

@@ -0,0 +1,123 @@
"""castle tool - manage tools."""
from __future__ import annotations
import argparse
from pathlib import Path
from castle_cli.config import load_config
BOLD = "\033[1m"
RESET = "\033[0m"
DIM = "\033[2m"
CYAN = "\033[96m"
def run_tool(args: argparse.Namespace) -> int:
"""Manage tools."""
if not args.tool_command:
print("Usage: castle tool {list|info}")
return 1
if args.tool_command == "list":
return _tool_list()
elif args.tool_command == "info":
return _tool_info(args.name)
return 1
def _tool_list() -> int:
"""List tools grouped by category."""
config = load_config()
tools = {k: v for k, v in config.components.items() if v.tool}
if not tools:
print("No tools registered.")
return 0
by_category: dict[str, list[tuple[str, object]]] = {}
for name, manifest in tools.items():
cat = manifest.tool.category or "uncategorized"
by_category.setdefault(cat, []).append((name, manifest))
for category in sorted(by_category):
items = by_category[category]
print(f"\n{BOLD}{CYAN}{category}{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, manifest in sorted(items):
tt = manifest.tool.tool_type.value
ver = manifest.tool.version
desc = manifest.description or ""
deps = ""
if manifest.tool.system_dependencies:
deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}"
print(f" {BOLD}{name:<20}{RESET} {ver:<6} {tt:<20} {desc}{deps}")
print()
return 0
def _tool_info(name: str) -> int:
"""Show detailed info about a tool, including .md documentation."""
config = load_config()
if name not in config.components:
print(f"Error: '{name}' not found")
return 1
manifest = config.components[name]
if not manifest.tool:
print(f"Error: '{name}' is not a tool")
return 1
t = manifest.tool
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
if manifest.description:
print(f" {manifest.description}")
print(f" {BOLD}type{RESET}: {t.tool_type.value}")
print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}")
print(f" {BOLD}version{RESET}: {t.version}")
if t.source:
print(f" {BOLD}source{RESET}: {t.source}")
if t.entry_point:
print(f" {BOLD}entry{RESET}: {t.entry_point}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
# Read and display .md documentation if available
if t.source:
md_path = _find_md_for_tool(config.root, t.source, name, t.category)
if md_path and md_path.exists():
content = md_path.read_text()
# Strip YAML frontmatter
if content.startswith("---\n"):
end = content.find("\n---\n", 4)
if end != -1:
content = content[end + 5:]
content = content.strip()
if content:
print(f"\n{BOLD}{CYAN}Documentation{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
print(f"{DIM}{content}{RESET}")
print()
return 0
def _find_md_for_tool(root: Path, source: str, tool_name: str, category: str | None = None) -> Path | None:
"""Find the .md documentation file for a tool source path."""
source_path = root / source
if source_path.is_file():
md = source_path.with_suffix(".md")
if md.exists():
return md
elif source_path.is_dir():
# Directory source — look for src/<category>/<tool_name>.md
py_name = tool_name.replace("-", "_")
if category:
md = source_path / "src" / category / f"{py_name}.md"
if md.exists():
return md
return None