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:
3
cli/src/castle_cli/__init__.py
Normal file
3
cli/src/castle_cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Castle CLI - manage projects, services, and infrastructure."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
cli/src/castle_cli/commands/__init__.py
Normal file
0
cli/src/castle_cli/commands/__init__.py
Normal file
121
cli/src/castle_cli/commands/create.py
Normal file
121
cli/src/castle_cli/commands/create.py
Normal 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
|
||||
118
cli/src/castle_cli/commands/dev.py
Normal file
118
cli/src/castle_cli/commands/dev.py
Normal 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
|
||||
186
cli/src/castle_cli/commands/gateway.py
Normal file
186
cli/src/castle_cli/commands/gateway.py
Normal 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
|
||||
116
cli/src/castle_cli/commands/info.py
Normal file
116
cli/src/castle_cli/commands/info.py
Normal 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
|
||||
81
cli/src/castle_cli/commands/list_cmd.py
Normal file
81
cli/src/castle_cli/commands/list_cmd.py
Normal 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
|
||||
69
cli/src/castle_cli/commands/logs.py
Normal file
69
cli/src/castle_cli/commands/logs.py
Normal 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
|
||||
96
cli/src/castle_cli/commands/run_cmd.py
Normal file
96
cli/src/castle_cli/commands/run_cmd.py
Normal 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
|
||||
517
cli/src/castle_cli/commands/service.py
Normal file
517
cli/src/castle_cli/commands/service.py
Normal 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
|
||||
103
cli/src/castle_cli/commands/sync.py
Normal file
103
cli/src/castle_cli/commands/sync.py
Normal 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
|
||||
123
cli/src/castle_cli/commands/tool.py
Normal file
123
cli/src/castle_cli/commands/tool.py
Normal 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
|
||||
217
cli/src/castle_cli/config.py
Normal file
217
cli/src/castle_cli/config.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Castle configuration and registry management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_cli.manifest import ComponentManifest, Role
|
||||
|
||||
|
||||
def find_castle_root() -> Path:
|
||||
"""Find the castle repository root by walking up from cwd looking for castle.yaml."""
|
||||
current = Path.cwd()
|
||||
while current != current.parent:
|
||||
if (current / "castle.yaml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Fallback: check if castle.yaml is in a well-known location
|
||||
default = Path("/data/repos/castle")
|
||||
if (default / "castle.yaml").exists():
|
||||
return default
|
||||
raise FileNotFoundError(
|
||||
"Could not find castle.yaml. Run castle from within the castle repository."
|
||||
)
|
||||
|
||||
|
||||
CASTLE_HOME = Path.home() / ".castle"
|
||||
GENERATED_DIR = CASTLE_HOME / "generated"
|
||||
SECRETS_DIR = CASTLE_HOME / "secrets"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Gateway configuration."""
|
||||
|
||||
port: int = 9000
|
||||
|
||||
|
||||
@dataclass
|
||||
class CastleConfig:
|
||||
"""Full castle configuration."""
|
||||
|
||||
root: Path
|
||||
gateway: GatewayConfig
|
||||
components: dict[str, ComponentManifest]
|
||||
|
||||
@property
|
||||
def services(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the SERVICE role."""
|
||||
return {
|
||||
k: v for k, v in self.components.items() if Role.SERVICE in v.roles
|
||||
}
|
||||
|
||||
@property
|
||||
def tools(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the TOOL role."""
|
||||
return {
|
||||
k: v for k, v in self.components.items() if Role.TOOL in v.roles
|
||||
}
|
||||
|
||||
@property
|
||||
def workers(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the WORKER role."""
|
||||
return {
|
||||
k: v for k, v in self.components.items() if Role.WORKER in v.roles
|
||||
}
|
||||
|
||||
@property
|
||||
def managed(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components managed by systemd."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.components.items()
|
||||
if v.manage and v.manage.systemd and v.manage.systemd.enable
|
||||
}
|
||||
|
||||
|
||||
def resolve_env_vars(
|
||||
env: dict[str, str], manifest: ComponentManifest
|
||||
) -> dict[str, str]:
|
||||
"""Resolve ${secret:NAME} references in env values."""
|
||||
resolved = {}
|
||||
for key, value in env.items():
|
||||
|
||||
def replace_var(match: re.Match[str]) -> str:
|
||||
ref = match.group(1)
|
||||
if ref.startswith("secret:"):
|
||||
secret_name = ref[7:]
|
||||
return _read_secret(secret_name)
|
||||
return match.group(0)
|
||||
|
||||
resolved[key] = re.sub(r"\$\{([^}]+)\}", replace_var, value)
|
||||
return resolved
|
||||
|
||||
|
||||
def _read_secret(name: str) -> str:
|
||||
"""Read a secret from ~/.castle/secrets/<name>. Returns placeholder if not found."""
|
||||
secret_path = SECRETS_DIR / name
|
||||
if secret_path.exists():
|
||||
return secret_path.read_text().strip()
|
||||
return f"<MISSING_SECRET:{name}>"
|
||||
|
||||
|
||||
def _parse_component(name: str, data: dict) -> ComponentManifest:
|
||||
"""Parse a components: entry directly into a ComponentManifest."""
|
||||
data_copy = dict(data)
|
||||
data_copy["id"] = name
|
||||
return ComponentManifest.model_validate(data_copy)
|
||||
|
||||
|
||||
def load_config(root: Path | None = None) -> CastleConfig:
|
||||
"""Load castle.yaml and return parsed configuration."""
|
||||
if root is None:
|
||||
root = find_castle_root()
|
||||
|
||||
config_path = root / "castle.yaml"
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Castle config not found: {config_path}")
|
||||
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
gateway_data = data.get("gateway", {})
|
||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
||||
|
||||
components: dict[str, ComponentManifest] = {}
|
||||
for name, comp_data in data.get("components", {}).items():
|
||||
components[name] = _parse_component(name, comp_data)
|
||||
|
||||
return CastleConfig(root=root, gateway=gateway, components=components)
|
||||
|
||||
|
||||
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
|
||||
"""Recursively remove empty lists and non-structural empty dicts."""
|
||||
if preserve_keys is None:
|
||||
preserve_keys = _STRUCTURAL_KEYS
|
||||
if isinstance(data, dict):
|
||||
cleaned = {}
|
||||
for k, v in data.items():
|
||||
v = _clean_for_yaml(v, preserve_keys)
|
||||
# Keep structural keys even if empty dict
|
||||
if k in preserve_keys and isinstance(v, dict):
|
||||
cleaned[k] = v
|
||||
continue
|
||||
# Skip empty collections
|
||||
if isinstance(v, (list, dict)) and not v:
|
||||
continue
|
||||
cleaned[k] = v
|
||||
return cleaned
|
||||
elif isinstance(data, list):
|
||||
return [_clean_for_yaml(item, preserve_keys) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
# Keys whose presence is structurally significant even with all-default values.
|
||||
# We serialize these as empty dicts `{}` so they survive a roundtrip.
|
||||
_STRUCTURAL_KEYS = {"manage", "systemd", "install", "path", "tool", "expose", "proxy", "caddy"}
|
||||
|
||||
|
||||
def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
|
||||
"""Serialize a manifest to a YAML-friendly dict, preserving structural presence."""
|
||||
full = manifest.model_dump(mode="json", exclude_none=True, exclude={"id", "roles"})
|
||||
minimal = manifest.model_dump(
|
||||
mode="json", exclude_none=True, exclude={"id", "roles"}, exclude_defaults=True
|
||||
)
|
||||
|
||||
def merge(full_val: object, min_val: object | None, key: str = "") -> object:
|
||||
if isinstance(full_val, dict):
|
||||
result = {}
|
||||
for k, fv in full_val.items():
|
||||
mv = min_val.get(k) if isinstance(min_val, dict) else None
|
||||
if k in _STRUCTURAL_KEYS:
|
||||
merged = merge(fv, mv, k)
|
||||
if merged is not None:
|
||||
result[k] = merged
|
||||
elif mv is not None:
|
||||
result[k] = merge(fv, mv, k)
|
||||
elif isinstance(fv, dict):
|
||||
merged = merge(fv, None, k)
|
||||
if merged:
|
||||
result[k] = merged
|
||||
return result if result else ({} if key in _STRUCTURAL_KEYS else result)
|
||||
elif isinstance(full_val, list):
|
||||
if min_val is not None:
|
||||
return full_val
|
||||
return []
|
||||
else:
|
||||
if min_val is not None:
|
||||
return full_val
|
||||
return None
|
||||
|
||||
result = merge(full, minimal)
|
||||
return _clean_for_yaml(result)
|
||||
|
||||
|
||||
def save_config(config: CastleConfig) -> None:
|
||||
"""Save castle configuration to castle.yaml."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}, "components": {}}
|
||||
|
||||
for name, manifest in config.components.items():
|
||||
data["components"][name] = _manifest_to_yaml_dict(manifest)
|
||||
|
||||
config_path = config.root / "castle.yaml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
"""Ensure castle directories exist."""
|
||||
CASTLE_HOME.mkdir(parents=True, exist_ok=True)
|
||||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(SECRETS_DIR, 0o700)
|
||||
212
cli/src/castle_cli/main.py
Normal file
212
cli/src/castle_cli/main.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Castle CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from castle_cli import __version__
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="castle",
|
||||
description="Castle platform CLI - manage projects, services, and infrastructure",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"castle {__version__}"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# castle list
|
||||
list_parser = subparsers.add_parser("list", help="List all components")
|
||||
list_parser.add_argument(
|
||||
"--role",
|
||||
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"],
|
||||
help="Filter by role",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--json", action="store_true", help="Output as JSON"
|
||||
)
|
||||
|
||||
# castle create
|
||||
create_parser = subparsers.add_parser("create", help="Create a new project")
|
||||
create_parser.add_argument("name", help="Project name")
|
||||
create_parser.add_argument(
|
||||
"--type",
|
||||
choices=["service", "tool", "library"],
|
||||
required=True,
|
||||
help="Project type",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--description", default="", help="Project description"
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--port", type=int, help="Port number (services only)"
|
||||
)
|
||||
|
||||
# castle info
|
||||
info_parser = subparsers.add_parser("info", help="Show component details")
|
||||
info_parser.add_argument("project", help="Component name")
|
||||
info_parser.add_argument(
|
||||
"--json", action="store_true", help="Output as JSON"
|
||||
)
|
||||
|
||||
# castle test
|
||||
test_parser = subparsers.add_parser("test", help="Run tests")
|
||||
test_parser.add_argument("project", nargs="?", help="Project to test (default: all)")
|
||||
|
||||
# castle lint
|
||||
lint_parser = subparsers.add_parser("lint", help="Run linter")
|
||||
lint_parser.add_argument("project", nargs="?", help="Project to lint (default: all)")
|
||||
|
||||
# castle sync
|
||||
subparsers.add_parser("sync", help="Sync submodules and install dependencies")
|
||||
|
||||
# castle gateway
|
||||
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
|
||||
gateway_sub = gateway_parser.add_subparsers(dest="gateway_command")
|
||||
gw_start = gateway_sub.add_parser("start", help="Start the gateway")
|
||||
gw_start.add_argument(
|
||||
"--dry-run", action="store_true", help="Print generated config without applying"
|
||||
)
|
||||
gateway_sub.add_parser("stop", help="Stop the gateway")
|
||||
gw_reload = gateway_sub.add_parser("reload", help="Reload gateway configuration")
|
||||
gw_reload.add_argument(
|
||||
"--dry-run", action="store_true", help="Print generated config without applying"
|
||||
)
|
||||
gateway_sub.add_parser("status", help="Show gateway status")
|
||||
|
||||
# castle service (singular - manage individual services)
|
||||
service_parser = subparsers.add_parser("service", help="Manage a service")
|
||||
service_sub = service_parser.add_subparsers(dest="service_command")
|
||||
enable_parser = service_sub.add_parser("enable", help="Enable and start a service")
|
||||
enable_parser.add_argument("name", help="Service name")
|
||||
enable_parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Print generated unit without installing"
|
||||
)
|
||||
disable_parser = service_sub.add_parser(
|
||||
"disable", help="Stop and disable a service"
|
||||
)
|
||||
disable_parser.add_argument("name", help="Service name")
|
||||
service_sub.add_parser("status", help="Show status of all services")
|
||||
|
||||
# castle services (plural - manage all services)
|
||||
services_parser = subparsers.add_parser(
|
||||
"services", help="Manage all services together"
|
||||
)
|
||||
services_sub = services_parser.add_subparsers(dest="services_command")
|
||||
services_sub.add_parser("start", help="Start all services and gateway")
|
||||
services_sub.add_parser("stop", help="Stop all services and gateway")
|
||||
|
||||
# castle logs
|
||||
logs_parser = subparsers.add_parser("logs", help="View component logs")
|
||||
logs_parser.add_argument("name", help="Component name")
|
||||
logs_parser.add_argument(
|
||||
"-f", "--follow", action="store_true", help="Follow log output"
|
||||
)
|
||||
logs_parser.add_argument(
|
||||
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
|
||||
)
|
||||
|
||||
# castle run
|
||||
run_parser = subparsers.add_parser("run", help="Run a component in the foreground")
|
||||
run_parser.add_argument("name", help="Component name")
|
||||
run_parser.add_argument(
|
||||
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component"
|
||||
)
|
||||
|
||||
# castle tool
|
||||
tool_parser = subparsers.add_parser("tool", help="Manage tools")
|
||||
tool_sub = tool_parser.add_subparsers(dest="tool_command")
|
||||
tool_sub.add_parser("list", help="List all tools by category")
|
||||
tool_info_parser = tool_sub.add_parser("info", help="Show tool details")
|
||||
tool_info_parser.add_argument("name", help="Tool name")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
# Import command handlers lazily to keep startup fast
|
||||
if args.command == "list":
|
||||
from castle_cli.commands.list_cmd import run_list
|
||||
|
||||
return run_list(args)
|
||||
|
||||
elif args.command == "info":
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
return run_info(args)
|
||||
|
||||
elif args.command == "create":
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
return run_create(args)
|
||||
|
||||
elif args.command == "test":
|
||||
from castle_cli.commands.dev import run_test
|
||||
|
||||
return run_test(args)
|
||||
|
||||
elif args.command == "lint":
|
||||
from castle_cli.commands.dev import run_lint
|
||||
|
||||
return run_lint(args)
|
||||
|
||||
elif args.command == "sync":
|
||||
from castle_cli.commands.sync import run_sync
|
||||
|
||||
return run_sync(args)
|
||||
|
||||
elif args.command == "gateway":
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
return run_gateway(args)
|
||||
|
||||
elif args.command == "service":
|
||||
from castle_cli.commands.service import run_service
|
||||
|
||||
return run_service(args)
|
||||
|
||||
elif args.command == "services":
|
||||
from castle_cli.commands.service import run_services
|
||||
|
||||
return run_services(args)
|
||||
|
||||
elif args.command == "logs":
|
||||
from castle_cli.commands.logs import run_logs
|
||||
|
||||
return run_logs(args)
|
||||
|
||||
elif args.command == "run":
|
||||
from castle_cli.commands.run_cmd import run_run
|
||||
|
||||
return run_run(args)
|
||||
|
||||
elif args.command == "tool":
|
||||
from castle_cli.commands.tool import run_tool
|
||||
|
||||
return run_tool(args)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""Entry point for the CLI."""
|
||||
sys.exit(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
315
cli/src/castle_cli/manifest.py
Normal file
315
cli/src/castle_cli/manifest.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Castle component manifest — Pydantic models for the component registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field, model_validator
|
||||
|
||||
EnvMap = dict[str, str]
|
||||
|
||||
|
||||
class RestartPolicy(str, Enum):
|
||||
NO = "no"
|
||||
ON_FAILURE = "on-failure"
|
||||
ALWAYS = "always"
|
||||
|
||||
|
||||
class TLSMode(str, Enum):
|
||||
OFF = "off"
|
||||
INTERNAL = "internal"
|
||||
LETSENCRYPT = "letsencrypt"
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
TOOL = "tool"
|
||||
SERVICE = "service"
|
||||
WORKER = "worker"
|
||||
FRONTEND = "frontend"
|
||||
JOB = "job"
|
||||
REMOTE = "remote"
|
||||
CONTAINERIZED = "containerized"
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Run specs (discriminated union)
|
||||
# ---------------------
|
||||
|
||||
|
||||
class RunBase(BaseModel):
|
||||
runner: str
|
||||
cwd: str | None = None
|
||||
env: EnvMap = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RunCommand(RunBase):
|
||||
runner: Literal["command"]
|
||||
argv: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class RunPythonModule(RunBase):
|
||||
runner: Literal["python_module"]
|
||||
module: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
python: str | None = None
|
||||
|
||||
|
||||
class RunPythonUvTool(RunBase):
|
||||
runner: Literal["python_uv_tool"]
|
||||
tool: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunContainer(RunBase):
|
||||
runner: Literal["container"]
|
||||
image: str
|
||||
command: list[str] | None = None
|
||||
args: list[str] = Field(default_factory=list)
|
||||
ports: dict[int, int] = Field(default_factory=dict)
|
||||
volumes: list[str] = Field(default_factory=list)
|
||||
workdir: str | None = None
|
||||
|
||||
|
||||
class RunNode(RunBase):
|
||||
runner: Literal["node"]
|
||||
script: str
|
||||
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
|
||||
args: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunRemote(RunBase):
|
||||
runner: Literal["remote"]
|
||||
base_url: str
|
||||
health_url: str | None = None
|
||||
|
||||
|
||||
RunSpec = Annotated[
|
||||
Union[RunCommand, RunPythonModule, RunPythonUvTool, RunContainer, RunNode, RunRemote],
|
||||
Field(discriminator="runner"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Triggers
|
||||
# ---------------------
|
||||
|
||||
|
||||
class TriggerManual(BaseModel):
|
||||
type: Literal["manual"] = "manual"
|
||||
|
||||
|
||||
class TriggerSchedule(BaseModel):
|
||||
type: Literal["schedule"] = "schedule"
|
||||
cron: str
|
||||
timezone: str = "America/Los_Angeles"
|
||||
|
||||
|
||||
class TriggerEvent(BaseModel):
|
||||
type: Literal["event"] = "event"
|
||||
source: str
|
||||
topic: str | None = None
|
||||
queue: str | None = None
|
||||
|
||||
|
||||
class TriggerRequest(BaseModel):
|
||||
type: Literal["request"] = "request"
|
||||
protocol: Literal["http", "https", "grpc"] = "http"
|
||||
|
||||
|
||||
TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest]
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Systemd management
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ReadinessHttpGet(BaseModel):
|
||||
http_get: str
|
||||
timeout_seconds: int = 2
|
||||
interval_seconds: int = 2
|
||||
success_codes: list[int] = Field(default_factory=lambda: [200])
|
||||
|
||||
|
||||
class SystemdSpec(BaseModel):
|
||||
enable: bool = True
|
||||
user: bool = True
|
||||
description: str | None = None
|
||||
after: list[str] = Field(default_factory=list)
|
||||
requires: list[str] = Field(default_factory=list)
|
||||
wanted_by: list[str] = Field(default_factory=lambda: ["default.target"])
|
||||
restart: RestartPolicy = RestartPolicy.ON_FAILURE
|
||||
restart_sec: int = 2
|
||||
no_new_privileges: bool = True
|
||||
readiness: ReadinessHttpGet | None = None
|
||||
|
||||
|
||||
class ManageSpec(BaseModel):
|
||||
systemd: SystemdSpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Install (PATH shims)
|
||||
# ---------------------
|
||||
|
||||
|
||||
class PathInstallSpec(BaseModel):
|
||||
enable: bool = True
|
||||
alias: str | None = None
|
||||
shim: bool = True
|
||||
|
||||
|
||||
class InstallSpec(BaseModel):
|
||||
path: PathInstallSpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Tool spec
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ToolType(str, Enum):
|
||||
PYTHON_UV = "python_uv"
|
||||
PYTHON_STANDALONE = "python_standalone"
|
||||
SCRIPT = "script"
|
||||
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
tool_type: ToolType = ToolType.PYTHON_UV
|
||||
category: str | None = None
|
||||
version: str = "1.0.0"
|
||||
source: str | None = None
|
||||
entry_point: str | None = None
|
||||
system_dependencies: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# HTTP exposure + proxy
|
||||
# ---------------------
|
||||
|
||||
|
||||
class HttpInternal(BaseModel):
|
||||
host: str = "127.0.0.1"
|
||||
port: int = Field(ge=1, le=65535)
|
||||
unix_socket: str | None = None
|
||||
|
||||
|
||||
class HttpPublic(BaseModel):
|
||||
hostnames: list[str] = Field(min_length=1)
|
||||
path_prefix: str = "/"
|
||||
tls: TLSMode = TLSMode.INTERNAL
|
||||
|
||||
|
||||
class HttpExposeSpec(BaseModel):
|
||||
internal: HttpInternal
|
||||
public: HttpPublic | None = None
|
||||
health_path: str | None = None
|
||||
|
||||
|
||||
class ExposeSpec(BaseModel):
|
||||
http: HttpExposeSpec | None = None
|
||||
|
||||
|
||||
class CaddySpec(BaseModel):
|
||||
enable: bool = True
|
||||
path_prefix: str | None = None
|
||||
extra_snippets: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProxySpec(BaseModel):
|
||||
caddy: CaddySpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Build spec
|
||||
# ---------------------
|
||||
|
||||
|
||||
class BuildSpec(BaseModel):
|
||||
commands: list[list[str]] = Field(default_factory=list)
|
||||
outputs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Capabilities
|
||||
# ---------------------
|
||||
|
||||
|
||||
class Capability(BaseModel):
|
||||
type: str
|
||||
name: str | None = None
|
||||
meta: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Component manifest
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ComponentManifest(BaseModel):
|
||||
id: str = ""
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
run: RunSpec | None = None
|
||||
|
||||
triggers: list[TriggerSpec] = Field(default_factory=list)
|
||||
|
||||
manage: ManageSpec | None = None
|
||||
install: InstallSpec | None = None
|
||||
tool: ToolSpec | None = None
|
||||
expose: ExposeSpec | None = None
|
||||
proxy: ProxySpec | None = None
|
||||
build: BuildSpec | None = None
|
||||
|
||||
provides: list[Capability] = Field(default_factory=list)
|
||||
consumes: list[Capability] = Field(default_factory=list)
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
roles: set[Role] = set()
|
||||
|
||||
if self.run:
|
||||
if self.run.runner == "remote":
|
||||
roles.add(Role.REMOTE)
|
||||
if self.run.runner == "container":
|
||||
roles.add(Role.CONTAINERIZED)
|
||||
|
||||
if self.install and self.install.path and self.install.path.enable:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
if self.tool:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
if self.expose and self.expose.http:
|
||||
roles.add(Role.SERVICE)
|
||||
|
||||
if (
|
||||
self.manage
|
||||
and self.manage.systemd
|
||||
and self.manage.systemd.enable
|
||||
and not (self.expose and self.expose.http)
|
||||
):
|
||||
roles.add(Role.WORKER)
|
||||
|
||||
if self.build and (self.build.outputs or self.build.commands):
|
||||
roles.add(Role.FRONTEND)
|
||||
|
||||
if any(getattr(t, "type", None) == "schedule" for t in self.triggers):
|
||||
roles.add(Role.JOB)
|
||||
|
||||
if not roles:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
return sorted(roles, key=lambda r: r.value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _basic_consistency(self) -> ComponentManifest:
|
||||
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||
if self.run and self.run.runner == "remote":
|
||||
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
|
||||
return self
|
||||
0
cli/src/castle_cli/templates/__init__.py
Normal file
0
cli/src/castle_cli/templates/__init__.py
Normal file
574
cli/src/castle_cli/templates/scaffold.py
Normal file
574
cli/src/castle_cli/templates/scaffold.py
Normal file
@@ -0,0 +1,574 @@
|
||||
"""Project scaffolding - generates project files from templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def scaffold_project(
|
||||
project_dir: Path,
|
||||
name: str,
|
||||
package_name: str,
|
||||
proj_type: str,
|
||||
description: str,
|
||||
port: int | None = None,
|
||||
) -> None:
|
||||
"""Scaffold a new project from templates."""
|
||||
if proj_type == "service":
|
||||
_scaffold_service(project_dir, name, package_name, description, port or 9000)
|
||||
elif proj_type == "tool":
|
||||
_scaffold_tool(project_dir, name, package_name, description)
|
||||
elif proj_type == "library":
|
||||
_scaffold_library(project_dir, name, package_name, description)
|
||||
else:
|
||||
raise ValueError(f"Unknown project type: {proj_type}")
|
||||
|
||||
|
||||
def _scaffold_service(
|
||||
project_dir: Path,
|
||||
name: str,
|
||||
package_name: str,
|
||||
description: str,
|
||||
port: int,
|
||||
) -> None:
|
||||
"""Scaffold a FastAPI service."""
|
||||
src_dir = project_dir / "src" / package_name
|
||||
tests_dir = project_dir / "tests"
|
||||
src_dir.mkdir(parents=True)
|
||||
tests_dir.mkdir(parents=True)
|
||||
|
||||
env_prefix = package_name.upper()
|
||||
|
||||
# pyproject.toml
|
||||
_write(
|
||||
project_dir / "pyproject.toml",
|
||||
f'''[project]
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = "{description}"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn>=0.34.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
{name} = "{package_name}.main:run"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/{package_name}"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["{package_name}"]
|
||||
''',
|
||||
)
|
||||
|
||||
# __init__.py
|
||||
_write(
|
||||
src_dir / "__init__.py",
|
||||
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
|
||||
)
|
||||
|
||||
# config.py
|
||||
_write(
|
||||
src_dir / "config.py",
|
||||
f'''"""Configuration for {name}."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Service settings loaded from environment variables."""
|
||||
|
||||
data_dir: Path = Path("./data")
|
||||
host: str = "0.0.0.0"
|
||||
port: int = {port}
|
||||
|
||||
model_config = {{
|
||||
"env_prefix": "{env_prefix}_",
|
||||
"env_file": ".env",
|
||||
}}
|
||||
|
||||
def ensure_data_dir(self) -> None:
|
||||
"""Create data directory if it doesn't exist."""
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
''',
|
||||
)
|
||||
|
||||
# main.py
|
||||
_write(
|
||||
src_dir / "main.py",
|
||||
f'''"""Main application for {name}."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from {package_name}.config import settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
settings.ensure_data_dir()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="{name}",
|
||||
description="{description}",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {{"status": "ok"}}
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Run the application with uvicorn."""
|
||||
uvicorn.run(
|
||||
"{package_name}.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
''',
|
||||
)
|
||||
|
||||
# tests/__init__.py
|
||||
_write(tests_dir / "__init__.py", "")
|
||||
|
||||
# tests/conftest.py
|
||||
_write(
|
||||
tests_dir / "conftest.py",
|
||||
f'''"""Test fixtures for {name}."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from {package_name}.config import settings
|
||||
from {package_name}.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temporary data directory for tests."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
original = settings.data_dir
|
||||
settings.data_dir = data_dir
|
||||
yield data_dir
|
||||
settings.data_dir = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(temp_data_dir: Path) -> Generator[TestClient, None, None]:
|
||||
"""Create a test client with isolated data directory."""
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
''',
|
||||
)
|
||||
|
||||
# tests/test_health.py
|
||||
_write(
|
||||
tests_dir / "test_health.py",
|
||||
f'''"""Tests for {name} health endpoint."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestHealth:
|
||||
"""Health endpoint tests."""
|
||||
|
||||
def test_health(self, client: TestClient) -> None:
|
||||
"""Health endpoint returns ok."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {{"status": "ok"}}
|
||||
''',
|
||||
)
|
||||
|
||||
# CLAUDE.md
|
||||
_write(
|
||||
project_dir / "CLAUDE.md",
|
||||
f"""# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working
|
||||
with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
{name} is a FastAPI service. {description}.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync # Install dependencies
|
||||
uv run {name} # Run service (port {port})
|
||||
uv run pytest tests/ -v # Run tests
|
||||
uv run ruff check . # Lint
|
||||
uv run ruff format . # Format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/{package_name}/config.py` — Settings via pydantic-settings, env prefix `{env_prefix}_`
|
||||
- `src/{package_name}/main.py` — FastAPI app, lifespan, health endpoint
|
||||
- `tests/` — pytest with TestClient fixtures
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables with `{env_prefix}_` prefix:
|
||||
- `{env_prefix}_DATA_DIR` — Data directory (default: ./data)
|
||||
- `{env_prefix}_HOST` — Bind host (default: 0.0.0.0)
|
||||
- `{env_prefix}_PORT` — Port (default: {port})
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _scaffold_tool(
|
||||
project_dir: Path,
|
||||
name: str,
|
||||
package_name: str,
|
||||
description: str,
|
||||
) -> None:
|
||||
"""Scaffold a CLI tool."""
|
||||
src_dir = project_dir / "src" / package_name
|
||||
tests_dir = project_dir / "tests"
|
||||
src_dir.mkdir(parents=True)
|
||||
tests_dir.mkdir(parents=True)
|
||||
|
||||
# pyproject.toml
|
||||
_write(
|
||||
project_dir / "pyproject.toml",
|
||||
f'''[project]
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = "{description}"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
{name} = "{package_name}.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/{package_name}"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["{package_name}"]
|
||||
''',
|
||||
)
|
||||
|
||||
# __init__.py
|
||||
_write(
|
||||
src_dir / "__init__.py",
|
||||
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
|
||||
)
|
||||
|
||||
# main.py
|
||||
_write(
|
||||
src_dir / "main.py",
|
||||
f'''#!/usr/bin/env python3
|
||||
"""{name}: {description}
|
||||
|
||||
Usage:
|
||||
{name} [options] [input]
|
||||
cat input.txt | {name}
|
||||
|
||||
Examples:
|
||||
{name} input.txt
|
||||
{name} input.txt -o output.txt
|
||||
cat input.txt | {name} > output.txt
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from {package_name} import __version__
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="{description}",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("input", nargs="?", help="Input file (default: stdin)")
|
||||
parser.add_argument(
|
||||
"-o", "--output", default=None, help="Output file (default: stdout)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"{name} {{__version__}}"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.input:
|
||||
with open(args.input) as f:
|
||||
data = f.read()
|
||||
else:
|
||||
data = sys.stdin.read()
|
||||
|
||||
# TODO: implement tool logic
|
||||
result = data
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(result)
|
||||
else:
|
||||
print(result, end="")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
''',
|
||||
)
|
||||
|
||||
# tests/__init__.py
|
||||
_write(tests_dir / "__init__.py", "")
|
||||
|
||||
# tests/test_main.py
|
||||
_write(
|
||||
tests_dir / "test_main.py",
|
||||
f'''"""Tests for {name}."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
class TestCLI:
|
||||
"""CLI interface tests."""
|
||||
|
||||
def test_version(self) -> None:
|
||||
"""--version prints version string."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "{package_name}.main", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert "{name}" in result.stdout
|
||||
assert "0.1.0" in result.stdout
|
||||
|
||||
def test_stdin(self) -> None:
|
||||
"""Reads from stdin when no file argument."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "{package_name}.main"],
|
||||
input="hello\\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "hello" in result.stdout
|
||||
|
||||
def test_file_input(self, tmp_path) -> None:
|
||||
"""Reads from file argument."""
|
||||
input_file = tmp_path / "input.txt"
|
||||
input_file.write_text("test data")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "{package_name}.main", str(input_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "test data" in result.stdout
|
||||
|
||||
def test_output_file(self, tmp_path) -> None:
|
||||
"""Writes to output file with -o flag."""
|
||||
input_file = tmp_path / "input.txt"
|
||||
input_file.write_text("test data")
|
||||
output_file = tmp_path / "output.txt"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "{package_name}.main",
|
||||
str(input_file), "-o", str(output_file),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert output_file.read_text() == "test data"
|
||||
''',
|
||||
)
|
||||
|
||||
# CLAUDE.md
|
||||
_write(
|
||||
project_dir / "CLAUDE.md",
|
||||
f"""# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working
|
||||
with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
{name} is a CLI tool. {description}.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync # Install dependencies
|
||||
uv run {name} # Run the tool
|
||||
uv run {name} --version # Show version
|
||||
uv run pytest tests/ -v # Run tests
|
||||
uv run ruff check . # Lint
|
||||
uv run ruff format . # Format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/{package_name}/main.py` — Entry point, argparse CLI, stdin/stdout interface
|
||||
- `src/{package_name}/__init__.py` — Package version (`__version__`)
|
||||
- `tests/` — pytest tests
|
||||
|
||||
## Conventions
|
||||
|
||||
- Reads from stdin or file argument
|
||||
- Writes to stdout or `-o/--output` file
|
||||
- `--version` flag for version info
|
||||
- Returns 0 on success, 1 on error
|
||||
- Composable via Unix pipes
|
||||
- `argparse.RawDescriptionHelpFormatter` with module docstring as epilog
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _scaffold_library(
|
||||
project_dir: Path,
|
||||
name: str,
|
||||
package_name: str,
|
||||
description: str,
|
||||
) -> None:
|
||||
"""Scaffold a Python library."""
|
||||
src_dir = project_dir / "src" / package_name
|
||||
tests_dir = project_dir / "tests"
|
||||
src_dir.mkdir(parents=True)
|
||||
tests_dir.mkdir(parents=True)
|
||||
|
||||
# pyproject.toml
|
||||
_write(
|
||||
project_dir / "pyproject.toml",
|
||||
f'''[project]
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = "{description}"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/{package_name}"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["{package_name}"]
|
||||
''',
|
||||
)
|
||||
|
||||
# __init__.py
|
||||
_write(
|
||||
src_dir / "__init__.py",
|
||||
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
|
||||
)
|
||||
|
||||
# tests/__init__.py
|
||||
_write(tests_dir / "__init__.py", "")
|
||||
|
||||
# tests/test_placeholder.py
|
||||
_write(
|
||||
tests_dir / "test_placeholder.py",
|
||||
f'''"""Tests for {name}."""
|
||||
|
||||
|
||||
class TestPlaceholder:
|
||||
"""Placeholder tests."""
|
||||
|
||||
def test_import(self) -> None:
|
||||
"""Library can be imported."""
|
||||
import {package_name}
|
||||
assert {package_name}.__version__ == "0.1.0"
|
||||
''',
|
||||
)
|
||||
|
||||
# CLAUDE.md
|
||||
_write(
|
||||
project_dir / "CLAUDE.md",
|
||||
f"""# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working
|
||||
with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
{name} is a Python library. {description}.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync # Install dependencies
|
||||
uv run pytest tests/ -v # Run tests
|
||||
uv run ruff check . # Lint
|
||||
uv run ruff format . # Format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/{package_name}/` — Library source code
|
||||
- `tests/` — pytest tests
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
"""Write content to a file, creating parent directories."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
Reference in New Issue
Block a user