Less stack-centric and location-centric model.

This commit is contained in:
2026-06-13 17:26:49 -07:00
parent 7bf98c17b7
commit 400e0b253b
33 changed files with 1112 additions and 408 deletions

View File

@@ -0,0 +1,134 @@
"""castle add — adopt an existing repo as a program (no scaffolding).
`castle create` makes new code from a stack. `castle add` adopts code that
already exists — a local path, or a git URL to clone. It detects sensible dev
verb commands so a non-castle project becomes usable without writing them by hand.
"""
from __future__ import annotations
import argparse
import tomllib
from pathlib import Path
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
def _is_git_url(s: str) -> bool:
return (
s.startswith(("http://", "https://", "git@", "ssh://"))
or s.endswith(".git")
)
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
"""Detect (stack, commands, behavior) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. behavior defaults to 'tool'.
"""
stack: str | None = None
commands: dict[str, list[list[str]]] = {}
behavior = "tool"
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
if "fastapi" in deps or "uvicorn" in deps:
stack, behavior = "python-fastapi", "daemon"
else:
stack = "python-cli"
return stack, commands, behavior
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands, "tool"
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands, "frontend"
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands, "tool"
return None, commands, "tool"
def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program."""
config = load_config()
target = args.target
repo_url: str | None = None
source: str | None = None
if _is_git_url(target):
repo_url = target
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(REPOS_DIR / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
print(f"Error: path does not exist: {src_path}")
return 1
source = str(src_path)
name = args.name or src_path.name
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
# Detect verbs from the working copy if we have one on disk.
stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {}
behavior = "tool"
if src_path.exists():
stack, detected_commands, behavior = _detect(src_path)
prog = ProgramSpec(
id=name,
description=args.description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
behavior=behavior,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands:
build_cmds = detected_commands.pop("build", None)
if build_cmds:
prog.build = BuildSpec(commands=build_cmds)
if detected_commands:
prog.commands = CommandsSpec.model_validate(detected_commands)
config.programs[name] = prog
save_config(config)
print(f"Adopted '{name}' as a program.")
print(f" source: {source}")
if repo_url:
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
if stack:
print(f" stack: {stack} (verbs inherited from stack defaults)")
elif detected_commands:
print(f" commands detected: {', '.join(sorted(detected_commands))}")
else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
return 0

View File

@@ -0,0 +1,62 @@
"""castle clone — clone source for programs that declare a `repo:` URL.
Used to provision a fresh machine: every program with a `repo:` and a missing
local `source:` gets cloned. A program whose source already exists is skipped.
"""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
from castle_cli.config import REPOS_DIR, load_config
def _clone_one(name: str, repo: str, source: str | None, ref: str | None) -> bool:
dest = Path(source) if source else REPOS_DIR / name
if dest.exists():
print(f" {name}: already present at {dest}, skipping")
return True
dest.parent.mkdir(parents=True, exist_ok=True)
cmd = ["git", "clone", repo, str(dest)]
print(f" {name}: cloning {repo}{dest}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" {name}: clone failed:\n{result.stderr}")
return False
if ref:
co = subprocess.run(
["git", "-C", str(dest), "checkout", ref], capture_output=True, text=True
)
if co.returncode != 0:
print(f" {name}: checkout {ref} failed:\n{co.stderr}")
return False
return True
def run_clone(args: argparse.Namespace) -> int:
config = load_config()
if getattr(args, "name", None):
if args.name not in config.programs:
print(f"Unknown program: {args.name}")
return 1
prog = config.programs[args.name]
if not prog.repo:
print(f"{args.name} has no repo: URL to clone from")
return 1
return 0 if _clone_one(args.name, prog.repo, prog.source, prog.ref) else 1
# Clone all programs that declare a repo: and lack a present source.
all_ok = True
cloned_any = False
for name, prog in config.programs.items():
if not prog.repo:
continue
cloned_any = True
if not _clone_one(name, prog.repo, prog.source, prog.ref):
all_ok = False
if not cloned_any:
print("No programs declare a repo: URL.")
return 0 if all_ok else 1

View File

@@ -3,15 +3,16 @@
from __future__ import annotations
import argparse
import subprocess
from castle_cli.config import load_config, save_config
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ProgramSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
ProgramSpec,
ProxySpec,
RunPython,
ServiceSpec,
@@ -43,21 +44,20 @@ def next_available_port(config: object) -> int:
def run_create(args: argparse.Namespace) -> int:
"""Create a new project."""
"""Create a new project (scaffolded from a stack, or a bare program)."""
config = load_config()
name = args.name
stack = args.stack
behavior = STACK_DEFAULTS.get(stack)
behavior = STACK_DEFAULTS.get(stack) if stack else None
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
code_dir = config.root / "code"
code_dir.mkdir(exist_ok=True)
project_dir = code_dir / name
REPOS_DIR.mkdir(parents=True, exist_ok=True)
project_dir = REPOS_DIR / name
if project_dir.exists():
print(f"Error: directory 'code/{name}' already exists")
print(f"Error: directory already exists: {project_dir}")
return 1
# Determine port for daemons
@@ -65,24 +65,29 @@ def run_create(args: argparse.Namespace) -> int:
if behavior == "daemon" and port is None:
port = next_available_port(config)
# Package name: convert kebab-case to snake_case
package_name = name.replace("-", "_")
description = args.description or (f"A castle {stack} program" if stack else f"{name}")
# Scaffold the project files
scaffold_project(
project_dir=project_dir,
name=name,
package_name=package_name,
stack=stack,
description=args.description or f"A castle {stack} program",
port=port,
)
if stack:
scaffold_project(
project_dir=project_dir,
name=name,
package_name=package_name,
stack=stack,
description=description,
port=port,
)
else:
# Bare program: empty source tree, no scaffold; user declares commands later.
project_dir.mkdir(parents=True)
# Initialize a git repo for the new source.
subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
# Build entries
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} program",
source=f"code/{name}",
description=description,
source=str(project_dir),
stack=stack,
behavior=behavior,
)
@@ -103,16 +108,20 @@ def run_create(args: argparse.Namespace) -> int:
save_config(config)
print(f"Created {stack} program '{name}' at {project_dir}")
label = f"{stack} program" if stack else "bare program"
print(f"Created {label} '{name}' at {project_dir}")
if port:
print(f" Port: {port}")
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd ~/.castle/code/{name}")
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name} # deploy to ~/.castle/")
print(f" castle test {name}")
print(f" cd {project_dir}")
if stack:
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name}")
print(f" castle test {name}")
else:
print(" # add code, then declare commands: in castle.yaml")
return 0

View File

@@ -0,0 +1,81 @@
"""castle delete — remove a program/service/job from the registry.
Config-only by default (removes the castle.yaml entry; leaves source and any
installed binary in place). Use --source to also delete the source directory.
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from castle_cli.config import load_config, save_config
def run_delete(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
# Find every section the name appears in.
in_programs = name in config.programs
in_services = name in config.services
in_jobs = name in config.jobs
if not (in_programs or in_services or in_jobs):
print(f"Error: '{name}' not found in castle.yaml")
return 1
where = [s for s, present in
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present]
# Resolve source dir (from the program entry) for the optional --source removal.
source_dir: Path | None = None
if in_programs and config.programs[name].source:
source_dir = Path(config.programs[name].source)
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).")
if args.source and source_dir:
print(f"Will ALSO delete source directory: {source_dir}")
# Confirm unless --yes.
if not args.yes:
prompt = f"Delete '{name}'? [y/N] "
try:
if input(prompt).strip().lower() not in ("y", "yes"):
print("Aborted.")
return 0
except EOFError:
print("Aborted (no input). Re-run with --yes to confirm non-interactively.")
return 1
# Remove registry entries.
if in_programs:
del config.programs[name]
if in_services:
del config.services[name]
if in_jobs:
del config.jobs[name]
save_config(config)
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).")
# Optional: delete the source directory.
if args.source and source_dir:
if source_dir.exists():
shutil.rmtree(source_dir)
print(f"Deleted source directory: {source_dir}")
else:
print(f"Source directory not found (already gone): {source_dir}")
# Warn about runtime artifacts we did NOT touch.
if in_services or in_jobs:
print(
f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n"
f" Run: castle service disable {name}"
)
if shutil.which(name):
print(
f"\nNote: '{name}' is still installed on PATH. To remove it:\n"
f" castle uninstall {name}"
)
return 0

View File

@@ -1,19 +1,23 @@
"""castle build / castle test / castle lint - run dev commands across projects."""
"""castle build/test/lint/type-check/check/run/install/uninstall — dev verbs.
Verbs resolve per-program: a declared command (manifest `commands:` / `build:`)
overrides the stack default, falling back to the stack handler, else unavailable.
"""
from __future__ import annotations
import argparse
import asyncio
from castle_core.stacks import get_handler
from castle_core.stacks import is_available, run_action
from castle_cli.config import CastleConfig, load_config
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
"""Run a stack action for a single project. Returns True on success."""
def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool:
"""Run a verb for a single program. Returns True on success."""
if project_name not in config.programs:
print(f"Unknown component: {project_name}")
print(f"Unknown program: {project_name}")
return False
comp = config.programs[project_name]
@@ -21,106 +25,69 @@ def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
print(f" {project_name}: no source directory, skipping")
return True
handler = get_handler(comp.stack)
if handler is None:
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
if not is_available(comp, verb):
print(f" {project_name}: '{verb}' not available (no declared command or stack), skipping")
return True
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" {action}: {project_name}")
print(f" {verb}: {project_name}")
print(f"{'' * 40}")
result = asyncio.run(method(project_name, comp, config.root))
result = asyncio.run(run_action(verb, project_name, comp, config.root))
if result.output:
print(result.output)
return result.status == "ok"
def run_test(args: argparse.Namespace) -> int:
"""Run tests for one or all projects."""
config = load_config()
if args.project:
success = _run_action(config, args.project, "test")
return 0 if success else 1
# Run all
def _run_verb_all(config: CastleConfig, verb: str) -> bool:
"""Run a verb across every program that supports it. Returns True if all pass."""
all_passed = True
ran_any = False
for name, comp in config.programs.items():
if not comp.source:
if not comp.source or not is_available(comp, verb):
continue
handler = get_handler(comp.stack)
if handler is None:
continue
# 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"):
ran_any = True
if not _run_verb(config, name, verb):
all_passed = False
if not ran_any:
print(f"No programs support '{verb}'.")
return all_passed
if all_passed:
print("\nAll tests passed.")
else:
print("\nSome tests failed.")
return 0 if all_passed else 1
def run_verb(args: argparse.Namespace, verb: str) -> int:
"""Generic entry point for a dev verb (single project or all)."""
config = load_config()
if getattr(args, "project", None):
return 0 if _run_verb(config, args.project, verb) else 1
ok = _run_verb_all(config, verb)
print(f"\n{'All ' + verb + ' passed.' if ok else 'Some ' + verb + ' failed.'}")
return 0 if ok else 1
# Thin named wrappers wired from main.py.
def run_test(args: argparse.Namespace) -> int:
return run_verb(args, "test")
def run_lint(args: argparse.Namespace) -> int:
"""Run linter for one or all projects."""
config = load_config()
if args.project:
success = _run_action(config, args.project, "lint")
return 0 if success else 1
# Run all
all_passed = True
for name, comp in config.programs.items():
if not comp.source:
continue
handler = get_handler(comp.stack)
if handler is None:
continue
if not _run_action(config, name, "lint"):
all_passed = False
if all_passed:
print("\nAll lint checks passed.")
else:
print("\nSome lint checks failed.")
return 0 if all_passed else 1
return run_verb(args, "lint")
def run_build(args: argparse.Namespace) -> int:
"""Run build for one or all projects."""
config = load_config()
return run_verb(args, "build")
if args.project:
success = _run_action(config, args.project, "build")
return 0 if success else 1
# Run all buildable projects
all_passed = True
for name, comp in config.programs.items():
if not comp.source:
continue
handler = get_handler(comp.stack)
if handler is None:
continue
if not _run_action(config, name, "build"):
all_passed = False
def run_type_check(args: argparse.Namespace) -> int:
return run_verb(args, "type-check")
if all_passed:
print("\nAll builds succeeded.")
else:
print("\nSome builds failed.")
return 0 if all_passed else 1
def run_check(args: argparse.Namespace) -> int:
return run_verb(args, "check")
def run_install(args: argparse.Namespace) -> int:
return run_verb(args, "install")
def run_uninstall(args: argparse.Namespace) -> int:
return run_verb(args, "uninstall")

View File

@@ -1,39 +1,72 @@
"""castle run - run a component in the foreground."""
"""castle run - run a program or service in the foreground.
Unified: if the target is a program with a declared `run` command, run that in
the foreground (dev-run). Otherwise fall back to the deployed-service run from
the registry.
"""
from __future__ import annotations
import argparse
import os
import subprocess
from pathlib import Path
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import load_config
def _run_program(name: str, extra: list[str]) -> int | None:
"""Run a program's declared `run` command in the foreground.
Returns the exit code, or None if the program has no declared run command
(so the caller can fall back to the deployed-service path).
"""
config = load_config()
prog = config.programs.get(name)
if prog is None or prog.commands is None or prog.commands.run is None:
return None
if not prog.source:
print(f"Error: program '{name}' has no source directory.")
return 1
cwd = Path(prog.source)
cmds = prog.commands.run
rc = 0
for i, argv in enumerate(cmds):
# Append extra args to the final command in the sequence.
full = list(argv) + (extra if i == len(cmds) - 1 else [])
print(f"Running {name}: {' '.join(full)}")
rc = subprocess.run(full, cwd=cwd).returncode
if rc != 0:
break
return rc
def run_run(args: argparse.Namespace) -> int:
"""Run a component in the foreground using the registry."""
"""Run a program (declared run) or a deployed service in the foreground."""
name = args.name
extra_args = getattr(args, "extra", []) or []
# 1. Program with a declared run command.
prog_rc = _run_program(name, extra_args)
if prog_rc is not None:
return prog_rc
# 2. Deployed service from the registry.
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
name = args.name
if name not in registry.deployed:
print(f"Error: component '{name}' not found in registry.")
print("Run 'castle deploy' to update the registry.")
print(f"Error: '{name}' is not a runnable program or deployed service.")
print("Declare a `run` command in castle.yaml, or run 'castle deploy'.")
return 1
deployed = registry.deployed[name]
# Build command with any extra args
extra_args = getattr(args, "extra", []) or []
cmd = list(deployed.run_cmd) + extra_args
# Merge environment
env = dict(os.environ)
env.update(deployed.env)
# Run in foreground (no cwd — registry-based, no repo dependency)
print(f"Running {name}: {' '.join(cmd)}")
result = subprocess.run(cmd, env=env)
return result.returncode
return subprocess.run(cmd, env=env).returncode

View File

@@ -1,75 +0,0 @@
"""castle tool - manage tools."""
from __future__ import annotations
import argparse
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 all registered tools."""
config = load_config()
tools = {k: v for k, v in config.programs.items() if v.behavior == "tool"}
if not tools:
print("No tools registered.")
return 0
print(f"\n{BOLD}{CYAN}Tools{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, manifest in sorted(tools.items()):
desc = manifest.description or ""
deps = ""
if manifest.system_dependencies:
deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}"
print(f" {BOLD}{name:<20}{RESET} {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.programs:
print(f"Error: '{name}' not found")
return 1
manifest = config.programs[name]
if manifest.behavior != "tool":
print(f"Error: '{name}' is not a tool")
return 1
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
if manifest.description:
print(f" {manifest.description}")
if manifest.version:
print(f" {BOLD}version{RESET}: {manifest.version}")
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
if manifest.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}")
print()
return 0