refactor: Update component specifications to use 'program' and 'behavior' attributes, enhancing clarity and consistency across the application

This commit is contained in:
2026-02-23 22:56:18 -08:00
parent 0d36e4f72a
commit efab2a7893
27 changed files with 258 additions and 393 deletions

View File

@@ -9,7 +9,7 @@ Castle is a personal software platform — a monorepo of independent projects
(services, tools, libraries) managed by the `castle` CLI. The registry
(`castle.yaml`) has three top-level sections:
- **`programs:`** — Software catalog (source, install, tool metadata, build)
- **`programs:`** — Software catalog (source, behavior, stack, system_dependencies, build)
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)

View File

@@ -5,7 +5,7 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
service: {
run: {
runner: "python",
tool: "",
program: "",
cwd: "",
env: {},
},
@@ -23,9 +23,7 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
},
},
tool: {
install: {
path: { alias: "" },
},
behavior: "tool",
},
job: {
run: {
@@ -72,7 +70,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
// Fill in template-specific fields
if (template === "service") {
const run = config.run as Record<string, unknown>
run.tool = name
run.program = name
run.cwd = name
const proxy = (config.proxy as Record<string, Record<string, string>>).caddy
proxy.path_prefix = `/${name}`
@@ -81,8 +79,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
expose.http.internal.port = parseInt(port, 10)
}
} else if (template === "tool") {
const install = (config.install as Record<string, Record<string, string>>).path
install.alias = name
// tool template has behavior preset, no extra config needed
}
await onAdd(name, config)

View File

@@ -115,7 +115,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
<Field label="Runner">
<span className="text-sm font-mono text-[var(--muted)]">
{runnerLabel(runner)}
{(m.run as Record<string, string>)?.tool && (
{(m.run as Record<string, string>)?.program && (
<> &middot; {(m.run as Record<string, string>).tool}</>
)}
</span>

View File

@@ -32,11 +32,18 @@ const ACTION_CONFIG: Record<string, ActionConfig> = {
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
export interface ActionOutput {
action: string
text: string
ok: boolean
}
interface ProgramActionsProps {
name: string
actions: string[]
installed?: boolean | null
compact?: boolean
onOutput?: (output: ActionOutput) => void
}
function visibleActions(
@@ -69,30 +76,27 @@ function visibleActions(
return visible
}
export function ProgramActions({ name, actions, installed, compact }: ProgramActionsProps) {
export function ProgramActions({ name, actions, installed, compact, onOutput }: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null)
const [output, setOutput] = useState<{ action: string; text: string; ok: boolean } | null>(null)
const visible = visibleActions(actions, installed, !!compact)
const handleAction = (action: string) => {
setRunningAction(action)
setOutput(null)
onOutput?.({ action: "", text: "", ok: true }) // clear previous
mutate(
{ name, action },
{
onSuccess: (data) => {
setRunningAction(null)
// Show output for dev actions on detail page
if (!compact && DEV_ACTIONS.includes(action) && data.output) {
setOutput({ action, text: data.output, ok: true })
onOutput?.({ action, text: data.output, ok: true })
}
},
onError: (err) => {
setRunningAction(null)
if (!compact && DEV_ACTIONS.includes(action)) {
// Extract detail from API error JSON
let text = String(err)
try {
const parsed = JSON.parse((err as Error).message)
@@ -100,7 +104,7 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
} catch {
text = (err as Error).message ?? text
}
setOutput({ action, text, ok: false })
onOutput?.({ action, text, ok: false })
}
},
},
@@ -110,7 +114,6 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
if (visible.length === 0) return null
return (
<div>
<div className="flex items-center gap-1 flex-wrap">
{visible.map((action) => {
const config = ACTION_CONFIG[action]
@@ -145,13 +148,17 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
)
})}
</div>
)
}
{output && !compact && (
<div className={`mt-3 rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
export function ActionOutputPanel({ output, onDismiss }: { output: ActionOutput; onDismiss: () => void }) {
if (!output.action) return null
return (
<div className={`rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
<div className={`flex items-center justify-between px-3 py-1.5 text-xs ${output.ok ? "text-green-400 bg-green-900/20" : "text-red-400 bg-red-900/20"}`}>
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} {output.ok ? "ok" : "error"}</span>
<button
onClick={() => setOutput(null)}
onClick={onDismiss}
className="text-[var(--muted)] hover:text-[var(--foreground)]"
>
dismiss
@@ -161,7 +168,5 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
{output.text}
</pre>
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,10 @@
import { useState } from "react"
import { useParams } from "react-router-dom"
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { ProgramActions } from "@/components/ProgramActions"
import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions"
export function ComponentDetailPage() {
useEventStream()
@@ -11,6 +12,7 @@ export function ComponentDetailPage() {
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
if (isLoading) {
return (
@@ -37,9 +39,15 @@ export function ComponentDetailPage() {
stack={component.stack}
source={component.source}
>
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} />
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} onOutput={setActionOutput} />
</DetailHeader>
{actionOutput && actionOutput.action && (
<div className="-mt-2 mb-6">
<ActionOutputPanel output={actionOutput} onDismiss={() => setActionOutput(null)} />
</div>
)}
{component.description && (
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
)}

View File

@@ -89,8 +89,8 @@ export function ServiceDetailPage() {
<span className="flex items-center gap-1">
<Terminal size={12} />
{runnerLabel(runner)}
{(component.manifest.run as Record<string, string>)?.tool && (
<> &middot; {(component.manifest.run as Record<string, string>).tool}</>
{(component.manifest.run as Record<string, string>)?.program && (
<> &middot; {(component.manifest.run as Record<string, string>).program}</>
)}
</span>
</>

View File

@@ -156,17 +156,6 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
# Determine behavior
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
behavior = "tool"
elif is_frontend:
behavior = "frontend"
else:
behavior = None
source = comp.source
# Infer runner from source directory
@@ -179,20 +168,19 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
runner = "command"
installed: bool | None = None
if comp.install and comp.install.path:
alias = comp.install.path.alias or name
installed = shutil.which(alias) is not None
if comp.source and comp.stack:
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
category="program",
description=comp.description,
behavior=behavior,
behavior=comp.behavior,
stack=comp.stack,
runner=runner,
version=comp.tool.version if comp.tool else None,
version=comp.version,
source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
system_dependencies=comp.system_dependencies,
installed=installed,
)
@@ -325,29 +313,6 @@ def _program_from_spec(
name: str, comp: ProgramSpec, root: Path, config: object | None = None
) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec."""
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
behavior = "tool"
elif is_frontend:
behavior = "frontend"
else:
behavior = None
# Derive behavior from backing service/job
if behavior is None and config is not None:
svc_components = {
s.component for s in config.services.values() if s.component
}
job_components = {
j.component for j in config.jobs.values() if j.component
}
if name in svc_components or name in config.services:
behavior = "daemon"
elif name in job_components or name in config.jobs:
behavior = "tool"
source = comp.source
runner = None
if source:
@@ -358,24 +323,18 @@ def _program_from_spec(
runner = "command"
installed: bool | None = None
if comp.install and comp.install.path:
alias = comp.install.path.alias or name
installed = shutil.which(alias) is not None
elif config is not None:
# Daemons: check if the service's run.tool binary is on PATH
svc = config.services.get(name)
if svc and hasattr(svc.run, "tool"):
installed = shutil.which(svc.run.tool) is not None
if comp.source and comp.stack:
installed = shutil.which(name) is not None
return ProgramSummary(
id=name,
description=comp.description,
behavior=behavior,
behavior=comp.behavior,
stack=comp.stack,
runner=runner,
version=comp.tool.version if comp.tool else None,
version=comp.version,
source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
system_dependencies=comp.system_dependencies,
installed=installed,
actions=available_actions(comp),
)
@@ -701,20 +660,10 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
if ref and ref in config.programs:
s.source = config.programs[ref].source
# Programs — always include entries from the programs
# section as catalog items. Derive behavior from backing
# service/job when the program has no install/build spec.
svc_components = {s.component for s in config.services.values() if s.component}
job_components = {j.component for j in config.jobs.values() if j.component}
# Programs from the software catalog
for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root)
if summary.behavior is None:
if name in svc_components or name in config.services:
summary.behavior = "daemon"
elif name in job_components or name in config.jobs:
summary.behavior = "tool"
else:
continue
summary.node = local_hostname
summaries.append(summary)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
@@ -16,18 +17,15 @@ router = APIRouter(tags=["tools"])
def _is_tool(comp: ProgramSpec) -> bool:
"""Check if a component is a tool (has install.path or tool spec)."""
return bool((comp.install and comp.install.path) or comp.tool)
"""Check if a component is a tool."""
return comp.behavior == "tool"
def _tool_summary(
name: str, comp: ProgramSpec, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a ProgramSpec that is a tool."""
t = comp.tool
installed = bool(
comp.install and comp.install.path and comp.install.path.enable
)
installed = shutil.which(name) is not None
# Infer runner from source directory
runner = None
@@ -43,9 +41,9 @@ def _tool_summary(
id=name,
description=comp.description,
source=source,
version=t.version if t else None,
version=comp.version,
runner=runner,
system_dependencies=t.system_dependencies if t else [],
system_dependencies=comp.system_dependencies,
installed=installed,
)

View File

@@ -23,30 +23,27 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
castle_yaml = tmp_path / "castle.yaml"
config = {
"gateway": {"port": 9000},
"components": {
"programs": {
"test-tool": {
"description": "Test tool",
"source": "test-tool",
"install": {"path": {"alias": "test-tool"}},
"tool": {
"behavior": "tool",
"system_dependencies": ["pandoc"],
},
},
"test-tool-2": {
"description": "Another test tool",
"source": "test-tool-2",
"tool": {
"behavior": "tool",
"version": "2.0.0",
},
},
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"description": "Test service",
"run": {
"runner": "python",
"tool": "test-svc",
"program": "test-svc",
},
"expose": {
"http": {

View File

@@ -33,18 +33,12 @@ class TestToolsList:
assert tool["system_dependencies"] == ["pandoc"]
def test_installed_flag(self, client: TestClient) -> None:
"""Tool with install.path is marked as installed."""
"""Tool installed field reflects whether binary is on PATH."""
response = client.get("/tools")
data = response.json()
tool = next(t for t in data if t["id"] == "test-tool")
assert tool["installed"] is True
def test_not_installed_flag(self, client: TestClient) -> None:
"""Tool without install.path is not marked as installed."""
response = client.get("/tools")
data = response.json()
tool = next(t for t in data if t["id"] == "test-tool-2")
assert tool["installed"] is False
# test-tool binary won't be on PATH in test env
assert isinstance(tool["installed"], bool)
def test_service_excluded(self, client: TestClient) -> None:
"""Services without tool spec are not listed."""

View File

@@ -7,34 +7,36 @@ programs:
on the LAN
source: components/central-context
stack: python-fastapi
behavior: daemon
notification-bridge:
description: Desktop notification forwarder. This taps into your notifications
system on the desktop and will forward all notifications the the central context
server.
source: components/notification-bridge
stack: python-fastapi
behavior: daemon
castle-api:
description: Castle API
source: castle-api
stack: python-fastapi
behavior: daemon
protonmail:
description: ProtonMail email sync via Bridge
source: components/protonmail
stack: python-cli
install:
path:
alias: protonmail
behavior: tool
backup-collect:
description: Collect files from various sources into backup directory
source: components/backup-collect
stack: python-cli
tool:
behavior: tool
system_dependencies:
- rsync
castle-app:
description: Castle web app
source: app
stack: react-vite
behavior: frontend
build:
commands:
- - pnpm
@@ -45,75 +47,53 @@ programs:
description: SSH tunnel manager with auto-reconnect
source: components/devbox-connect
stack: python-cli
install:
path:
alias: devbox-connect
behavior: tool
mbox2eml:
description: MBOX to EML email converter
source: components/mbox2eml
stack: python-cli
install:
path:
alias: mbox2eml
behavior: tool
android-backup:
description: Backup Android device using ADB
source: components/android-backup
stack: python-cli
install:
path:
alias: android-backup
tool:
behavior: tool
system_dependencies:
- adb
browser:
description: Browse the web using natural language via browser-use
source: components/browser
stack: python-cli
install:
path:
alias: browser
behavior: tool
docx-extractor:
description: Extract content and metadata from Word .docx files
source: components/docx-extractor
stack: python-cli
install:
path:
alias: docx-extractor
tool:
behavior: tool
system_dependencies:
- pandoc
docx2md:
description: Convert Word .docx files to Markdown
source: components/docx2md
stack: python-cli
install:
path:
alias: docx2md
tool:
behavior: tool
system_dependencies:
- pandoc
gpt:
description: OpenAI text generation utility
source: components/gpt
stack: python-cli
install:
path:
alias: gpt
behavior: tool
html2text:
description: Convert HTML content to plain text
source: components/html2text
stack: python-cli
install:
path:
alias: html2text
behavior: tool
md2pdf:
description: Convert Markdown files to PDF
source: components/md2pdf
stack: python-cli
install:
path:
alias: md2pdf
tool:
behavior: tool
system_dependencies:
- pandoc
- texlive-latex-base
@@ -121,24 +101,17 @@ programs:
description: Combine text files into a single markdown document
source: components/mdscraper
stack: python-cli
install:
path:
alias: mdscraper
behavior: tool
pdf-extractor:
description: Extract content and metadata from PDF files
source: components/pdf-extractor
stack: python-cli
install:
path:
alias: pdf-extractor
behavior: tool
pdf2md:
description: Convert PDF files to Markdown
source: components/pdf2md
stack: python-cli
install:
path:
alias: pdf2md
tool:
behavior: tool
system_dependencies:
- pandoc
- poppler-utils
@@ -146,23 +119,17 @@ programs:
description: Manage systemd user timers and scheduled tasks
source: components/schedule
stack: python-cli
install:
path:
alias: schedule
behavior: tool
search:
description: Manage self-contained searchable collections of files
source: components/search
stack: python-cli
install:
path:
alias: search
behavior: tool
text-extractor:
description: Extract content and metadata from text files
source: components/text-extractor
stack: python-cli
install:
path:
alias: text-extractor
behavior: tool
services:
castle-gateway:
@@ -189,7 +156,7 @@ services:
component: central-context
run:
runner: python
tool: central-context
program: central-context
manage:
systemd: {}
expose:
@@ -204,7 +171,7 @@ services:
component: notification-bridge
run:
runner: python
tool: notification-bridge
program: notification-bridge
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
@@ -224,7 +191,7 @@ services:
component: castle-api
run:
runner: python
tool: castle-api
program: castle-api
manage:
systemd: {}
expose:

View File

@@ -11,14 +11,11 @@ from castle_cli.manifest import (
ExposeSpec,
HttpExposeSpec,
HttpInternal,
InstallSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
RunPython,
ServiceSpec,
SystemdSpec,
ToolSpec,
)
from castle_cli.templates.scaffold import scaffold_project
@@ -82,19 +79,18 @@ def run_create(args: argparse.Namespace) -> int:
)
# Build entries
if behavior == "daemon":
# Program for software identity
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
behavior=behavior,
)
# Service for deployment
if behavior == "daemon":
config.services[name] = ServiceSpec(
id=name,
component=name,
run=RunPython(runner="python", tool=name),
run=RunPython(runner="python", program=name),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=port),
@@ -104,23 +100,6 @@ def run_create(args: argparse.Namespace) -> int:
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()),
)
elif behavior == "tool":
config.programs[name] = ProgramSpec(
id=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.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
)
save_config(config)

View File

@@ -216,13 +216,13 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec."""
match run.runner:
case "python":
resolved = shutil.which(run.tool)
resolved = shutil.which(run.program)
if not resolved:
print(
f" Warning: '{run.tool}' not on PATH. "
f" Warning: '{run.program}' not on PATH. "
f"Install with: uv tool install --editable <source>"
)
cmd = [resolved or run.tool]
cmd = [resolved or run.program]
if run.args:
cmd.extend(run.args)
return cmd

View File

@@ -52,17 +52,15 @@ def run_info(args: argparse.Namespace) -> int:
print(f"{'' * 40}")
# Determine behavior
if service:
print(f" {BOLD}behavior{RESET}: daemon")
behavior = None
if program and program.behavior:
behavior = program.behavior
elif service:
behavior = "daemon"
elif job:
print(f" {BOLD}behavior{RESET}: tool")
elif program:
if program.tool or (program.install and program.install.path):
print(f" {BOLD}behavior{RESET}: tool")
elif program.build:
print(f" {BOLD}behavior{RESET}: frontend")
else:
print(f" {BOLD}behavior{RESET}: —")
behavior = "tool"
if behavior:
print(f" {BOLD}behavior{RESET}: {behavior}")
# Show stack
stack = None
@@ -81,13 +79,8 @@ def run_info(args: argparse.Namespace) -> int:
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 program.tool:
t = program.tool
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
if program.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(program.system_dependencies)}")
if program.tags:
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
@@ -104,8 +97,8 @@ def run_info(args: argparse.Namespace) -> int:
# Run spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
if hasattr(spec.run, "tool"):
print(f" {BOLD}tool{RESET}: {spec.run.tool}")
if hasattr(spec.run, "program"):
print(f" {BOLD}program{RESET}: {spec.run.program}")
elif hasattr(spec.run, "argv"):
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
elif hasattr(spec.run, "image"):
@@ -185,15 +178,14 @@ def _info_json(
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"})
if program and program.behavior:
data["behavior"] = program.behavior
elif service:
data["behavior"] = "daemon"
elif job:
data["behavior"] = "tool"
if not service and not job and program:
if program.tool or (program.install and program.install.path):
data["behavior"] = "tool"
elif program.build:
data["behavior"] = "frontend"
# Resolve stack
stack = None

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.programs.items() if v.tool}
tools = {k: v for k, v in config.programs.items() if v.behavior == "tool"}
if not tools:
print("No tools registered.")
@@ -40,8 +40,8 @@ def _tool_list() -> int:
for name, manifest in sorted(tools.items()):
desc = manifest.description or ""
deps = ""
if manifest.tool and manifest.tool.system_dependencies:
deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}"
if manifest.system_dependencies:
deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}"
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
print()
@@ -56,21 +56,20 @@ def _tool_info(name: str) -> int:
return 1
manifest = config.programs[name]
if not manifest.tool:
if manifest.behavior != "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}version{RESET}: {t.version}")
if manifest.version:
print(f" {BOLD}version{RESET}: {manifest.version}")
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
if manifest.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}")
print()
return 0

View File

@@ -12,10 +12,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
HttpExposeSpec,
HttpInternal,
HttpPublic,
InstallSpec,
JobSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
ReadinessHttpGet,
RestartPolicy,
@@ -29,5 +27,4 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
ServiceSpec,
SystemdSpec,
TLSMode,
ToolSpec,
)

View File

@@ -15,12 +15,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
castle_yaml = tmp_path / "castle.yaml"
config = {
"gateway": {"port": 18000},
"components": {
"programs": {
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
"behavior": "tool",
},
},
"services": {
@@ -29,7 +27,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service",
"run": {
"runner": "python",
"tool": "test-svc",
"program": "test-svc",
},
"defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},

View File

@@ -70,8 +70,7 @@ class TestCreateCommand:
assert (project_dir / "CLAUDE.md").exists()
assert "my-tool2" in config.programs
comp = config.programs["my-tool2"]
assert comp.tool is not None
assert comp.install is not None
assert comp.behavior == "tool"
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails."""

8
components/android-backup/uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "android-backup"
version = "0.1.0"
source = { editable = "." }

View File

@@ -53,11 +53,11 @@ class CastleConfig:
@property
def tools(self) -> dict[str, ProgramSpec]:
"""Return programs that are tools (have install.path or tool spec)."""
"""Return programs that are tools (behavior == 'tool')."""
return {
k: v
for k, v in self.programs.items()
if (v.install and v.install.path) or v.tool
if v.behavior == "tool"
}
@property
@@ -180,9 +180,6 @@ def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> obje
_STRUCTURAL_KEYS = {
"manage",
"systemd",
"install",
"path",
"tool",
"expose",
"proxy",
"caddy",

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
EnvMap = dict[str, str]
@@ -38,7 +38,7 @@ class RunCommand(RunBase):
class RunPython(RunBase):
runner: Literal["python"]
tool: str
program: str
args: list[str] = Field(default_factory=list)
@@ -102,33 +102,6 @@ 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 ToolSpec(BaseModel):
model_config = ConfigDict(extra="ignore")
version: str = "1.0.0"
system_dependencies: list[str] = Field(default_factory=list)
# ---------------------
# HTTP exposure + proxy
# ---------------------
@@ -206,12 +179,13 @@ class ProgramSpec(BaseModel):
id: str = ""
description: str | None = None
behavior: str | None = None
source: str | None = None
stack: str | None = None
install: InstallSpec | None = None
tool: ToolSpec | None = None
system_dependencies: list[str] = Field(default_factory=list)
version: str | None = None
build: BuildSpec | None = None
provides: list[Capability] = Field(default_factory=list)

View File

@@ -15,12 +15,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
castle_yaml = tmp_path / "castle.yaml"
config = {
"gateway": {"port": 18000},
"components": {
"programs": {
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
"behavior": "tool",
},
},
"services": {
@@ -29,7 +27,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service",
"run": {
"runner": "python",
"tool": "test-svc",
"program": "test-svc",
},
"defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},

View File

@@ -51,7 +51,7 @@ class TestLoadConfig:
config = load_config(castle_root)
svc = config.services["test-svc"]
assert svc.run.runner == "python"
assert svc.run.tool == "test-svc"
assert svc.run.program == "test-svc"
def test_service_component_ref(self, castle_root: Path) -> None:
"""Service references a component."""

View File

@@ -10,17 +10,14 @@ from castle_core.manifest import (
ExposeSpec,
HttpExposeSpec,
HttpInternal,
InstallSpec,
JobSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
RunCommand,
RunPython,
RunRemote,
ServiceSpec,
SystemdSpec,
ToolSpec,
)
@@ -32,26 +29,27 @@ class TestProgramSpec:
c = ProgramSpec(id="bare")
assert c.description is None
assert c.source is None
assert c.install is None
assert c.tool is None
assert c.behavior is None
assert c.build is None
def test_tool_component(self) -> None:
"""Component with tool and install specs."""
"""Component with tool behavior and system_dependencies."""
c = ProgramSpec(
id="my-tool",
description="A tool",
source="my-tool/",
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias="my-tool")),
behavior="tool",
system_dependencies=["pandoc"],
)
assert c.source == "my-tool/"
assert c.install.path.alias == "my-tool"
assert c.behavior == "tool"
assert c.system_dependencies == ["pandoc"]
def test_frontend_component(self) -> None:
"""Component with build spec."""
c = ProgramSpec(
id="my-app",
behavior="frontend",
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
)
assert c.build.outputs == ["dist/"]
@@ -74,7 +72,7 @@ class TestServiceSpec:
"""Service with run and expose."""
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", tool="svc"),
run=RunPython(runner="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
)
assert s.run.runner == "python"
@@ -85,7 +83,7 @@ class TestServiceSpec:
s = ServiceSpec(
id="svc",
component="my-component",
run=RunPython(runner="python", tool="svc"),
run=RunPython(runner="python", program="svc"),
)
assert s.component == "my-component"
@@ -93,7 +91,7 @@ class TestServiceSpec:
"""Service with proxy spec."""
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", tool="svc"),
run=RunPython(runner="python", program="svc"),
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
)
assert s.proxy.caddy.path_prefix == "/svc"
@@ -174,15 +172,14 @@ class TestModelSerialization:
c = ProgramSpec(id="test", description="Test")
data = c.model_dump(exclude_none=True, exclude={"id"})
assert "description" in data
assert "install" not in data
assert "tool" not in data
assert "build" not in data
def test_dump_service(self) -> None:
"""Full service serializes correctly."""
s = ServiceSpec(
id="svc",
description="A service",
run=RunPython(runner="python", tool="svc"),
run=RunPython(runner="python", program="svc"),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health"

View File

@@ -16,9 +16,8 @@ programs:
my-tool:
description: Does something useful
source: programs/my-tool
install:
path: { alias: my-tool }
tool:
stack: python-cli
behavior: tool
system_dependencies: [pandoc]
services:
@@ -26,7 +25,7 @@ services:
component: my-service
run:
runner: python
tool: my-service
program: my-service
expose:
http:
internal: { port: 9001 }
@@ -51,7 +50,7 @@ jobs:
| Section | Purpose | Category |
|---------|---------|----------|
| `programs:` | Software catalog — what exists | tool, frontend, program |
| `programs:` | Software catalog — what exists | tool, frontend, daemon |
| `services:` | Long-running daemons — how they run | service |
| `jobs:` | Scheduled tasks — when they run | job |
@@ -61,7 +60,18 @@ fallthrough and source code linking. They can also exist independently
## Program blocks
Programs define **what software exists** — identity, source, tools, builds.
Programs define **what software exists** — identity, source, behavior, builds.
### `behavior` — What role this program plays
```yaml
behavior: daemon # or: tool, frontend
```
Explicit declaration of how the program is used:
- **daemon** — long-running service (python-fastapi stack)
- **tool** — CLI utility (python-cli stack)
- **frontend** — web UI (react-vite stack)
### `source` — Where the source lives
@@ -71,28 +81,30 @@ source: programs/my-tool
Relative path from repo root to the project directory.
### `install` — How to install it
### `stack` — Development toolchain
```yaml
install:
path:
alias: my-tool # Command name in PATH
stack: python-fastapi # or: python-cli, react-vite
```
Creates a shim so the tool is available system-wide after
`uv tool install --editable .`.
Stacks define how programs get built, checked, and installed.
### `tool` — Tool metadata
### `system_dependencies` — Required system packages
```yaml
tool:
version: "1.0.0"
system_dependencies: [pandoc, poppler-utils]
system_dependencies: [pandoc, poppler-utils]
```
This block provides metadata for `castle tool list` and the dashboard.
It's separate from `install` (which handles PATH registration). The source
directory is set via the top-level `source` field on the program, not here.
System packages that must be installed for the program to work. Displayed
in `castle tool list` and the dashboard.
### `version` — Program version
```yaml
version: "1.0.0"
```
Optional version metadata.
### `build` — How to build it
@@ -104,7 +116,7 @@ build:
- dist/
```
Components with build outputs are categorized as **frontends** in the UI.
Programs with build outputs are typically frontends.
## Service blocks
@@ -116,7 +128,7 @@ Discriminated union on `runner`:
| Runner | Sync | Deploy | Key fields |
|--------|------|--------|------------|
| `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` |
| `python` | `uv sync` | `which(program)` → installed binary | `program`, `args` |
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
@@ -125,7 +137,7 @@ Discriminated union on `runner`:
```yaml
run:
runner: python
tool: my-service # name in [project.scripts]
program: my-service # name in [project.scripts]
```
### `expose` — What it exposes
@@ -225,22 +237,23 @@ programs:
my-tool:
description: Does something useful
source: programs/my-tool
install:
path:
alias: my-tool
stack: python-cli
behavior: tool
# Service — needs both program and service entries
programs:
my-service:
description: Does something useful
source: programs/my-service
stack: python-fastapi
behavior: daemon
services:
my-service:
component: my-service
run:
runner: python
tool: my-service
program: my-service
expose:
http:
internal: { port: 9001 }
@@ -317,11 +330,11 @@ and a `.timer` file.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ProgramSpec` — software catalog entry (source, install, tool, build)
- `ProgramSpec` — software catalog entry (source, behavior, stack, build, system_dependencies)
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py``load_config()` parses

View File

@@ -318,22 +318,19 @@ programs:
my-tool:
description: Does something useful
source: components/my-tool
install:
path:
alias: my-tool
stack: python-cli
behavior: tool
```
Tools with system dependencies declare them in the component:
Tools with system dependencies declare them directly on the program:
```yaml
programs:
pdf2md:
description: Convert PDF files to Markdown
source: components/pdf2md
install:
path:
alias: pdf2md
tool:
stack: python-cli
behavior: tool
system_dependencies: [pandoc, poppler-utils]
```

View File

@@ -103,13 +103,15 @@ programs:
my-service:
description: Does something useful
source: components/my-service
stack: python-fastapi
behavior: daemon
services:
my-service:
component: my-service
run:
runner: python
tool: my-service
program: my-service
expose:
http:
internal: { port: 9001 }