Refactor component terminology to programs in config and manifest

- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications.
- Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks.
- Adjusted tests to reflect the new program structure and ensure proper functionality.
- Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
2026-02-23 22:09:41 -08:00
parent f559fba143
commit 0d36e4f72a
48 changed files with 7512 additions and 632 deletions

View File

@@ -7,7 +7,7 @@ import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ComponentSpec,
ProgramSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -52,15 +52,15 @@ def run_create(args: argparse.Namespace) -> int:
stack = args.stack
behavior = STACK_DEFAULTS.get(stack)
if name in config.components or name in config.services or name in config.jobs:
if name in config.programs or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
components_dir = config.root / "components"
components_dir.mkdir(exist_ok=True)
project_dir = components_dir / name
programs_dir = config.root / "programs"
programs_dir.mkdir(exist_ok=True)
project_dir = programs_dir / name
if project_dir.exists():
print(f"Error: directory 'components/{name}' already exists")
print(f"Error: directory 'programs/{name}' already exists")
return 1
# Determine port for daemons
@@ -77,17 +77,17 @@ def run_create(args: argparse.Namespace) -> int:
name=name,
package_name=package_name,
stack=stack,
description=args.description or f"A castle {stack} component",
description=args.description or f"A castle {stack} program",
port=port,
)
# Build entries
if behavior == "daemon":
# Component for software identity
config.components[name] = ComponentSpec(
# Program for software identity
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
)
# Service for deployment
@@ -105,31 +105,31 @@ def run_create(args: argparse.Namespace) -> int:
manage=ManageSpec(systemd=SystemdSpec()),
)
elif behavior == "tool":
config.components[name] = ComponentSpec(
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
# frontend or other
config.components[name] = ComponentSpec(
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
)
save_config(config)
print(f"Created {stack} component '{name}' at {project_dir}")
print(f"Created {stack} program '{name}' at {project_dir}")
if port:
print(f" Port: {port}")
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd components/{name}")
print(f" cd programs/{name}")
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")

View File

@@ -37,16 +37,16 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/."""
"""Deploy from castle.yaml to ~/.castle/."""
config = load_config()
target_name = getattr(args, "component", None)
target_name = getattr(args, "name", None)
ensure_dirs()
# Build node config
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
# Load existing registry to preserve components not being redeployed,
# Load existing registry to preserve entries not being redeployed,
# or start fresh if deploying all
if target_name and REGISTRY_PATH.exists():
try:
@@ -96,24 +96,24 @@ def run_deploy(args: argparse.Namespace) -> int:
# Reload systemd daemon
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
print(f"\nDeployed {deployed_count} component(s).")
print(f"\nDeployed {deployed_count} item(s).")
print("Run 'castle services start' to start all services.")
return 0
def _env_prefix(name: str) -> str:
"""Derive env var prefix from component name: central-context → CENTRAL_CONTEXT."""
"""Derive env var prefix from name: central-context → CENTRAL_CONTEXT."""
return name.replace("-", "_").upper()
def _resolve_description(
config: CastleConfig, spec: ServiceSpec | JobSpec
) -> str | None:
"""Get description, falling through to component if referenced."""
"""Get description, falling through to program if referenced."""
if spec.description:
return spec.description
if spec.component and spec.component in config.components:
return config.components[spec.component].description
if spec.component and spec.component in config.programs:
return config.programs[spec.component].description
return None
@@ -155,10 +155,10 @@ def _build_deployed_service(
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
# Resolve stack from referenced component
# Resolve stack from referenced program
stack = None
if svc.component and svc.component in config.components:
stack = config.components[svc.component].stack
if svc.component and svc.component in config.programs:
stack = config.programs[svc.component].stack
return DeployedComponent(
runner=run.runner,
@@ -195,10 +195,10 @@ def _build_deployed_job(
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Resolve stack from referenced component
# Resolve stack from referenced program
stack = None
if job.component and job.component in config.components:
stack = config.components[job.component].stack
if job.component and job.component in config.programs:
stack = config.programs[job.component].stack
return DeployedComponent(
runner=run.runner,
@@ -275,10 +275,10 @@ def _print_deployed(name: str, deployed: DeployedComponent) -> None:
def _copy_app_static(config: CastleConfig) -> None:
"""Copy castle-app build output to ~/.castle/static/castle-app/."""
if "castle-app" not in config.components:
if "castle-app" not in config.programs:
return
comp = config.components["castle-app"]
comp = config.programs["castle-app"]
if not (comp.build and comp.build.outputs):
return

View File

@@ -3,37 +3,44 @@
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
import asyncio
from castle_core.stacks import get_handler
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]
working_dir = manifest.source_dir or project_name
return config.root / working_dir
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
"""Run a stack action for a single project. Returns True on success."""
if project_name not in config.programs:
print(f"Unknown component: {project_name}")
return False
comp = config.programs[project_name]
if not comp.source:
print(f" {project_name}: no source directory, skipping")
return True
def _has_pyproject(project_dir: Path) -> bool:
"""Check if a project directory has a pyproject.toml."""
return (project_dir / "pyproject.toml").exists()
handler = get_handler(comp.stack)
if handler is None:
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
return True
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
method_name = action.replace("-", "_")
method = getattr(handler, method_name, None)
if method is None:
print(f" {project_name}: action '{action}' not supported")
return False
print(f"\n{'' * 40}")
print(f" {label}: {project_dir.name}")
print(f" {action}: {project_name}")
print(f"{'' * 40}")
result = subprocess.run(cmd, cwd=project_dir)
return result.returncode == 0
result = asyncio.run(method(project_name, comp, config.root))
if result.output:
print(result.output)
return result.status == "ok"
def run_test(args: argparse.Namespace) -> int:
@@ -41,34 +48,23 @@ def run_test(args: argparse.Namespace) -> int:
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",
)
success = _run_action(config, args.project, "test")
return 0 if success else 1
# Run all
all_passed = True
for name, manifest in config.components.items():
working_dir = manifest.source_dir or name
project_dir = config.root / working_dir
tests_dir = project_dir / "tests"
if not tests_dir.exists():
for name, comp in config.programs.items():
if not comp.source:
continue
if not _has_pyproject(project_dir):
handler = get_handler(comp.stack)
if handler is None:
continue
success = _run_in_project(
project_dir,
["uv", "run", "pytest", "tests/", "-v"],
"Testing",
)
if not success:
# Skip projects without a tests directory (python) or test script (node)
source_dir = config.root / comp.source
if comp.stack in ("python-cli", "python-fastapi"):
if not (source_dir / "tests").exists():
continue
if not _run_action(config, name, "test"):
all_passed = False
if all_passed:
@@ -83,27 +79,18 @@ def run_lint(args: argparse.Namespace) -> int:
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",
)
success = _run_action(config, args.project, "lint")
return 0 if success else 1
# Run all
all_passed = True
for name, manifest in config.components.items():
working_dir = manifest.source_dir or name
project_dir = config.root / working_dir
if not _has_pyproject(project_dir):
for name, comp in config.programs.items():
if not comp.source:
continue
success = _run_in_project(
project_dir,
["uv", "run", "ruff", "check", "."],
"Linting",
)
if not success:
handler = get_handler(comp.stack)
if handler is None:
continue
if not _run_action(config, name, "lint"):
all_passed = False
if all_passed:

View File

@@ -1,4 +1,4 @@
"""castle info - show detailed component information."""
"""castle info - show detailed program information."""
from __future__ import annotations
@@ -17,8 +17,8 @@ GREEN = "\033[92m"
RED = "\033[91m"
def _load_deployed_component(name: str) -> object | None:
"""Try to load a specific deployed component from registry."""
def _load_deployed_program(name: str) -> object | None:
"""Try to load a specific deployed program from registry."""
try:
from castle_core.registry import load_registry
@@ -29,23 +29,23 @@ def _load_deployed_component(name: str) -> object | None:
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a component, service, or job."""
"""Show detailed info for a program, service, or job."""
config = load_config()
name = args.project
# Look up in all sections
component = config.components.get(name)
program = config.programs.get(name)
service = config.services.get(name)
job = config.jobs.get(name)
if not component and not service and not job:
if not program and not service and not job:
print(f"Error: '{name}' not found in castle.yaml")
return 1
deployed = _load_deployed_component(name)
deployed = _load_deployed_program(name)
if getattr(args, "json", False):
return _info_json(config, name, component, service, job, deployed)
return _info_json(config, name, program, service, job, deployed)
# Human-readable output
print(f"\n{BOLD}{name}{RESET}")
@@ -56,51 +56,51 @@ def run_info(args: argparse.Namespace) -> int:
print(f" {BOLD}behavior{RESET}: daemon")
elif job:
print(f" {BOLD}behavior{RESET}: tool")
elif component:
if component.tool or (component.install and component.install.path):
elif program:
if program.tool or (program.install and program.install.path):
print(f" {BOLD}behavior{RESET}: tool")
elif component.build:
elif program.build:
print(f" {BOLD}behavior{RESET}: frontend")
else:
print(f" {BOLD}behavior{RESET}: —")
# Show stack
stack = None
if component and component.stack:
stack = component.stack
elif service and service.component and service.component in config.components:
stack = config.components[service.component].stack
elif job and job.component and job.component in config.components:
stack = config.components[job.component].stack
if program and program.stack:
stack = program.stack
elif service and service.component and service.component in config.programs:
stack = config.programs[service.component].stack
elif job and job.component and job.component in config.programs:
stack = config.programs[job.component].stack
if stack:
print(f" {BOLD}stack{RESET}: {stack}")
# Component info
if component:
if component.description:
print(f" {BOLD}description{RESET}: {component.description}")
if component.source:
print(f" {BOLD}source{RESET}: {component.source}")
if component.install and component.install.path:
pi = component.install.path
# Program info
if program:
if program.description:
print(f" {BOLD}description{RESET}: {program.description}")
if program.source:
print(f" {BOLD}source{RESET}: {program.source}")
if program.install and program.install.path:
pi = program.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
if component.tool:
t = component.tool
if program.tool:
t = program.tool
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
if component.tags:
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
if program.tags:
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
# Service info
spec = service or job
if spec:
desc = spec.description
if not desc and spec.component and spec.component in config.components:
desc = config.components[spec.component].description
if desc and not (component and component.description == desc):
if not desc and spec.component and spec.component in config.programs:
desc = config.programs[spec.component].description
if desc and not (program and program.description == desc):
print(f" {BOLD}description{RESET}: {desc}")
if spec.component:
print(f" {BOLD}component{RESET}: {spec.component}")
print(f" {BOLD}program{RESET}: {spec.component}")
# Run spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
@@ -154,10 +154,10 @@ def run_info(args: argparse.Namespace) -> int:
# Show CLAUDE.md if it exists
source_dir = None
if component and component.source_dir:
source_dir = component.source_dir
elif spec and spec.component and spec.component in config.components:
source_dir = config.components[spec.component].source_dir
if program and program.source_dir:
source_dir = program.source_dir
elif spec and spec.component and spec.component in config.programs:
source_dir = config.programs[spec.component].source_dir
if source_dir:
claude_md = _find_claude_md(config.root, source_dir)
@@ -173,7 +173,7 @@ def run_info(args: argparse.Namespace) -> int:
def _info_json(
config: object,
name: str,
component: object | None,
program: object | None,
service: object | None,
job: object | None,
deployed: object | None,
@@ -181,28 +181,28 @@ def _info_json(
"""Output JSON info."""
data: dict = {"name": name}
if component:
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
if program:
data["program"] = program.model_dump(exclude_none=True, exclude={"id"})
if service:
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
data["behavior"] = "daemon"
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
data["behavior"] = "tool"
if not service and not job and component:
if component.tool or (component.install and component.install.path):
if not service and not job and program:
if program.tool or (program.install and program.install.path):
data["behavior"] = "tool"
elif component.build:
elif program.build:
data["behavior"] = "frontend"
# Resolve stack
stack = None
if component and component.stack:
stack = component.stack
elif service and service.component and service.component in config.components:
stack = config.components[service.component].stack
elif job and job.component and job.component in config.components:
stack = config.components[job.component].stack
if program and program.stack:
stack = program.stack
elif service and service.component and service.component in config.programs:
stack = config.programs[service.component].stack
elif job and job.component and job.component in config.programs:
stack = config.programs[job.component].stack
if stack:
data["stack"] = stack

View File

@@ -1,4 +1,4 @@
"""castle list - show all registered components."""
"""castle list - show all registered programs, services, and jobs."""
from __future__ import annotations
@@ -50,27 +50,27 @@ def _load_deployed() -> dict[str, object] | None:
def _resolve_stack(config: object, name: str) -> str | None:
"""Resolve stack from component reference or direct component."""
# Check services for component ref
"""Resolve stack from program reference or direct program."""
# Check services for program ref
if name in config.services:
svc = config.services[name]
comp_name = svc.component
if comp_name and comp_name in config.components:
return config.components[comp_name].stack
# Check jobs for component ref
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Check jobs for program ref
if name in config.jobs:
job = config.jobs[name]
comp_name = job.component
if comp_name and comp_name in config.components:
return config.components[comp_name].stack
# Direct component
if name in config.components:
return config.components[name].stack
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Direct program
if name in config.programs:
return config.programs[name].stack
return None
def run_list(args: argparse.Namespace) -> int:
"""List all components, services, and jobs."""
"""List all programs, services, and jobs."""
config = load_config()
deployed = _load_deployed()
@@ -123,12 +123,12 @@ def run_list(args: argparse.Namespace) -> int:
sched = f" {DIM}[{job.schedule}]{RESET}"
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
# Components (tools, frontends, etc.)
# Programs (tools, frontends, etc.)
show_tools = not filter_behavior or filter_behavior == "tool"
show_frontends = not filter_behavior or filter_behavior == "frontend"
if show_tools or show_frontends:
# Collect non-daemon components
# Collect non-daemon programs
comps: dict[str, tuple[str, str | None, str | None]] = {}
if show_tools:
@@ -147,7 +147,7 @@ def run_list(args: argparse.Namespace) -> int:
if comps:
any_output = True
color = CYAN
print(f"\n{BOLD}{color}Components{RESET}")
print(f"\n{BOLD}{color}Programs{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, (behavior, stack, description) in comps.items():
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
@@ -156,7 +156,7 @@ def run_list(args: argparse.Namespace) -> int:
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
if not any_output:
print("No components found.")
print("No programs found.")
if deployed is None:
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")

View File

@@ -28,7 +28,7 @@ def run_sync(args: argparse.Namespace) -> int:
all_ok = True
synced_dirs: set[Path] = set()
for name, comp in config.components.items():
for name, comp in config.programs.items():
source_dir = comp.source_dir
if not source_dir:
continue
@@ -63,7 +63,7 @@ def run_sync(args: argparse.Namespace) -> int:
installed_dirs: set[Path] = set()
# Install components with install.path
for name, comp in config.components.items():
for name, comp in config.programs.items():
if not (comp.install and comp.install.path):
continue
source = comp.source_dir
@@ -77,8 +77,8 @@ def run_sync(args: argparse.Namespace) -> int:
continue
# Find source from component reference
source = None
if svc.component and svc.component in config.components:
source = config.components[svc.component].source_dir
if svc.component and svc.component in config.programs:
source = config.programs[svc.component].source_dir
if not source:
continue
source_dir = config.root / source

View File

@@ -29,7 +29,7 @@ def run_tool(args: argparse.Namespace) -> int:
def _tool_list() -> int:
"""List all registered tools."""
config = load_config()
tools = {k: v for k, v in config.components.items() if v.tool}
tools = {k: v for k, v in config.programs.items() if v.tool}
if not tools:
print("No tools registered.")
@@ -51,11 +51,11 @@ def _tool_list() -> int:
def _tool_info(name: str) -> int:
"""Show detailed info about a tool, including .md documentation."""
config = load_config()
if name not in config.components:
if name not in config.programs:
print(f"Error: '{name}' not found")
return 1
manifest = config.components[name]
manifest = config.programs[name]
if not manifest.tool:
print(f"Error: '{name}' is not a tool")
return 1

View File

@@ -19,7 +19,7 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# castle list
list_parser = subparsers.add_parser("list", help="List all components")
list_parser = subparsers.add_parser("list", help="List all programs, services, and jobs")
list_parser.add_argument(
"--behavior",
choices=["daemon", "tool", "frontend"],
@@ -43,8 +43,8 @@ def build_parser() -> argparse.ArgumentParser:
create_parser.add_argument("--description", default="", help="Project description")
create_parser.add_argument("--port", type=int, help="Port number (daemons only)")
# castle info
info_parser = subparsers.add_parser("info", help="Show component details")
info_parser.add_argument("project", help="Component name")
info_parser = subparsers.add_parser("info", help="Show program details")
info_parser.add_argument("project", help="Program, service, or job name")
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
# castle test
@@ -91,25 +91,25 @@ def build_parser() -> argparse.ArgumentParser:
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 = subparsers.add_parser("logs", help="View service/job logs")
logs_parser.add_argument("name", help="Service or job 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 = subparsers.add_parser("run", help="Run a service in the foreground")
run_parser.add_argument("name", help="Service name")
run_parser.add_argument(
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component"
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the service"
)
# castle deploy
deploy_parser = subparsers.add_parser(
"deploy", help="Deploy components to ~/.castle/ (spec → runtime)"
"deploy", help="Deploy to ~/.castle/ (spec → runtime)"
)
deploy_parser.add_argument("component", nargs="?", help="Component to deploy (default: all)")
deploy_parser.add_argument("name", nargs="?", help="Service or job to deploy (default: all)")
# castle tool
tool_parser = subparsers.add_parser("tool", help="Manage tools")

View File

@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
BuildSpec,
CaddySpec,
Capability,
ComponentSpec,
ProgramSpec,
DefaultsSpec,
EnvMap,
ExposeSpec,