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 (services, tools, libraries) managed by the `castle` CLI. The registry
(`castle.yaml`) has three top-level sections: (`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) - **`services:`** — Long-running daemons (run, expose, proxy, systemd)
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer) - **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)

View File

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

View File

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

View File

@@ -32,11 +32,18 @@ const ACTION_CONFIG: Record<string, ActionConfig> = {
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"] const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
export interface ActionOutput {
action: string
text: string
ok: boolean
}
interface ProgramActionsProps { interface ProgramActionsProps {
name: string name: string
actions: string[] actions: string[]
installed?: boolean | null installed?: boolean | null
compact?: boolean compact?: boolean
onOutput?: (output: ActionOutput) => void
} }
function visibleActions( function visibleActions(
@@ -69,30 +76,27 @@ function visibleActions(
return visible return visible
} }
export function ProgramActions({ name, actions, installed, compact }: ProgramActionsProps) { export function ProgramActions({ name, actions, installed, compact, onOutput }: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction() const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null) 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 visible = visibleActions(actions, installed, !!compact)
const handleAction = (action: string) => { const handleAction = (action: string) => {
setRunningAction(action) setRunningAction(action)
setOutput(null) onOutput?.({ action: "", text: "", ok: true }) // clear previous
mutate( mutate(
{ name, action }, { name, action },
{ {
onSuccess: (data) => { onSuccess: (data) => {
setRunningAction(null) setRunningAction(null)
// Show output for dev actions on detail page
if (!compact && DEV_ACTIONS.includes(action) && data.output) { if (!compact && DEV_ACTIONS.includes(action) && data.output) {
setOutput({ action, text: data.output, ok: true }) onOutput?.({ action, text: data.output, ok: true })
} }
}, },
onError: (err) => { onError: (err) => {
setRunningAction(null) setRunningAction(null)
if (!compact && DEV_ACTIONS.includes(action)) { if (!compact && DEV_ACTIONS.includes(action)) {
// Extract detail from API error JSON
let text = String(err) let text = String(err)
try { try {
const parsed = JSON.parse((err as Error).message) const parsed = JSON.parse((err as Error).message)
@@ -100,7 +104,7 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
} catch { } catch {
text = (err as Error).message ?? text text = (err as Error).message ?? text
} }
setOutput({ action, text, ok: false }) onOutput?.({ action, text, ok: false })
} }
}, },
}, },
@@ -110,58 +114,59 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
if (visible.length === 0) return null if (visible.length === 0) return null
return ( return (
<div> <div className="flex items-center gap-1 flex-wrap">
<div className="flex items-center gap-1 flex-wrap"> {visible.map((action) => {
{visible.map((action) => { const config = ACTION_CONFIG[action]
const config = ACTION_CONFIG[action] if (!config) return null
if (!config) return null const Icon = config.icon
const Icon = config.icon const isRunning = isPending && runningAction === action
const isRunning = isPending && runningAction === action
if (compact) {
return (
<button
key={action}
onClick={() => handleAction(action)}
disabled={isPending}
className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
title={config.label}
>
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
</button>
)
}
if (compact) {
return ( return (
<button <button
key={action} key={action}
onClick={() => handleAction(action)} onClick={() => handleAction(action)}
disabled={isPending} disabled={isPending}
className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`} className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
title={config.label}
> >
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />} {isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
{config.label}
</button> </button>
) )
})} }
</div>
{output && !compact && ( return (
<div className={`mt-3 rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}> <button
<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"}`}> key={action}
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} {output.ok ? "ok" : "error"}</span> onClick={() => handleAction(action)}
<button disabled={isPending}
onClick={() => setOutput(null)} className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
className="text-[var(--muted)] hover:text-[var(--foreground)]" >
> {isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
dismiss {config.label}
</button> </button>
</div> )
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40"> })}
{output.text} </div>
</pre> )
</div> }
)}
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={onDismiss}
className="text-[var(--muted)] hover:text-[var(--foreground)]"
>
dismiss
</button>
</div>
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40">
{output.text}
</pre>
</div> </div>
) )
} }

View File

@@ -1,9 +1,10 @@
import { useState } from "react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks" import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels" import { runnerLabel } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { ProgramActions } from "@/components/ProgramActions" import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions"
export function ComponentDetailPage() { export function ComponentDetailPage() {
useEventStream() useEventStream()
@@ -11,6 +12,7 @@ export function ComponentDetailPage() {
const { data: component, isLoading, error, refetch } = useProgram(name ?? "") const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool" const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
if (isLoading) { if (isLoading) {
return ( return (
@@ -37,9 +39,15 @@ export function ComponentDetailPage() {
stack={component.stack} stack={component.stack}
source={component.source} 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> </DetailHeader>
{actionOutput && actionOutput.action && (
<div className="-mt-2 mb-6">
<ActionOutputPanel output={actionOutput} onDismiss={() => setActionOutput(null)} />
</div>
)}
{component.description && ( {component.description && (
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p> <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"> <span className="flex items-center gap-1">
<Terminal size={12} /> <Terminal size={12} />
{runnerLabel(runner)} {runnerLabel(runner)}
{(component.manifest.run as Record<string, string>)?.tool && ( {(component.manifest.run as Record<string, string>)?.program && (
<> &middot; {(component.manifest.run as Record<string, string>).tool}</> <> &middot; {(component.manifest.run as Record<string, string>).program}</>
)} )}
</span> </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: def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ProgramSpec (tools/frontends).""" """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 source = comp.source
# Infer runner from source directory # Infer runner from source directory
@@ -179,20 +168,19 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
runner = "command" runner = "command"
installed: bool | None = None installed: bool | None = None
if comp.install and comp.install.path: if comp.source and comp.stack:
alias = comp.install.path.alias or name installed = shutil.which(name) is not None
installed = shutil.which(alias) is not None
return ComponentSummary( return ComponentSummary(
id=name, id=name,
category="program", category="program",
description=comp.description, description=comp.description,
behavior=behavior, behavior=comp.behavior,
stack=comp.stack, stack=comp.stack,
runner=runner, runner=runner,
version=comp.tool.version if comp.tool else None, version=comp.version,
source=source, source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [], system_dependencies=comp.system_dependencies,
installed=installed, installed=installed,
) )
@@ -325,29 +313,6 @@ def _program_from_spec(
name: str, comp: ProgramSpec, root: Path, config: object | None = None name: str, comp: ProgramSpec, root: Path, config: object | None = None
) -> ProgramSummary: ) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec.""" """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 source = comp.source
runner = None runner = None
if source: if source:
@@ -358,24 +323,18 @@ def _program_from_spec(
runner = "command" runner = "command"
installed: bool | None = None installed: bool | None = None
if comp.install and comp.install.path: if comp.source and comp.stack:
alias = comp.install.path.alias or name installed = shutil.which(name) is not None
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
return ProgramSummary( return ProgramSummary(
id=name, id=name,
description=comp.description, description=comp.description,
behavior=behavior, behavior=comp.behavior,
stack=comp.stack, stack=comp.stack,
runner=runner, runner=runner,
version=comp.tool.version if comp.tool else None, version=comp.version,
source=source, source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [], system_dependencies=comp.system_dependencies,
installed=installed, installed=installed,
actions=available_actions(comp), actions=available_actions(comp),
) )
@@ -701,21 +660,11 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
if ref and ref in config.programs: if ref and ref in config.programs:
s.source = config.programs[ref].source s.source = config.programs[ref].source
# Programs — always include entries from the programs # Programs from the software catalog
# 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}
for name, comp in config.programs.items(): for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root) summary = _summary_from_program(name, comp, root)
if summary.behavior is None: if summary.behavior is None:
if name in svc_components or name in config.services: continue
summary.behavior = "daemon"
elif name in job_components or name in config.jobs:
summary.behavior = "tool"
else:
continue
summary.node = local_hostname summary.node = local_hostname
summaries.append(summary) summaries.append(summary)
except FileNotFoundError: except FileNotFoundError:

View File

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

View File

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

View File

@@ -33,18 +33,12 @@ class TestToolsList:
assert tool["system_dependencies"] == ["pandoc"] assert tool["system_dependencies"] == ["pandoc"]
def test_installed_flag(self, client: TestClient) -> None: 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") response = client.get("/tools")
data = response.json() data = response.json()
tool = next(t for t in data if t["id"] == "test-tool") tool = next(t for t in data if t["id"] == "test-tool")
assert tool["installed"] is True # test-tool binary won't be on PATH in test env
assert isinstance(tool["installed"], bool)
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
def test_service_excluded(self, client: TestClient) -> None: def test_service_excluded(self, client: TestClient) -> None:
"""Services without tool spec are not listed.""" """Services without tool spec are not listed."""

View File

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

View File

@@ -11,14 +11,11 @@ from castle_cli.manifest import (
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
InstallSpec,
ManageSpec, ManageSpec,
PathInstallSpec,
ProxySpec, ProxySpec,
RunPython, RunPython,
ServiceSpec, ServiceSpec,
SystemdSpec, SystemdSpec,
ToolSpec,
) )
from castle_cli.templates.scaffold import scaffold_project from castle_cli.templates.scaffold import scaffold_project
@@ -82,19 +79,18 @@ def run_create(args: argparse.Namespace) -> int:
) )
# Build entries # Build entries
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
behavior=behavior,
)
if behavior == "daemon": 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,
)
# Service for deployment
config.services[name] = ServiceSpec( config.services[name] = ServiceSpec(
id=name, id=name,
component=name, component=name,
run=RunPython(runner="python", tool=name), run=RunPython(runner="python", program=name),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=port), internal=HttpInternal(port=port),
@@ -104,23 +100,6 @@ def run_create(args: argparse.Namespace) -> int:
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")), proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()), 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) 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.""" """Build a run command list from a RunSpec."""
match run.runner: match run.runner:
case "python": case "python":
resolved = shutil.which(run.tool) resolved = shutil.which(run.program)
if not resolved: if not resolved:
print( print(
f" Warning: '{run.tool}' not on PATH. " f" Warning: '{run.program}' not on PATH. "
f"Install with: uv tool install --editable <source>" f"Install with: uv tool install --editable <source>"
) )
cmd = [resolved or run.tool] cmd = [resolved or run.program]
if run.args: if run.args:
cmd.extend(run.args) cmd.extend(run.args)
return cmd return cmd

View File

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

View File

@@ -29,7 +29,7 @@ def run_tool(args: argparse.Namespace) -> int:
def _tool_list() -> int: def _tool_list() -> int:
"""List all registered tools.""" """List all registered tools."""
config = load_config() 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: if not tools:
print("No tools registered.") print("No tools registered.")
@@ -40,8 +40,8 @@ def _tool_list() -> int:
for name, manifest in sorted(tools.items()): for name, manifest in sorted(tools.items()):
desc = manifest.description or "" desc = manifest.description or ""
deps = "" deps = ""
if manifest.tool and manifest.tool.system_dependencies: if manifest.system_dependencies:
deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}" deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}"
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}") print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
print() print()
@@ -56,21 +56,20 @@ def _tool_info(name: str) -> int:
return 1 return 1
manifest = config.programs[name] manifest = config.programs[name]
if not manifest.tool: if manifest.behavior != "tool":
print(f"Error: '{name}' is not a tool") print(f"Error: '{name}' is not a tool")
return 1 return 1
t = manifest.tool
print(f"\n{BOLD}{name}{RESET}") print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}") print(f"{'' * 40}")
if manifest.description: if manifest.description:
print(f" {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: if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}") print(f" {BOLD}source{RESET}: {manifest.source}")
if t.system_dependencies: if manifest.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}")
print() print()
return 0 return 0

View File

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

View File

@@ -15,12 +15,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
castle_yaml = tmp_path / "castle.yaml" castle_yaml = tmp_path / "castle.yaml"
config = { config = {
"gateway": {"port": 18000}, "gateway": {"port": 18000},
"components": { "programs": {
"test-tool": { "test-tool": {
"description": "Test tool", "description": "Test tool",
"install": { "behavior": "tool",
"path": {"alias": "test-tool"},
},
}, },
}, },
"services": { "services": {
@@ -29,7 +27,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service", "description": "Test service",
"run": { "run": {
"runner": "python", "runner": "python",
"tool": "test-svc", "program": "test-svc",
}, },
"defaults": { "defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")}, "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 (project_dir / "CLAUDE.md").exists()
assert "my-tool2" in config.programs assert "my-tool2" in config.programs
comp = config.programs["my-tool2"] comp = config.programs["my-tool2"]
assert comp.tool is not None assert comp.behavior == "tool"
assert comp.install is not None
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails.""" """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 @property
def tools(self) -> dict[str, ProgramSpec]: 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 { return {
k: v k: v
for k, v in self.programs.items() for k, v in self.programs.items()
if (v.install and v.install.path) or v.tool if v.behavior == "tool"
} }
@property @property
@@ -180,9 +180,6 @@ def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> obje
_STRUCTURAL_KEYS = { _STRUCTURAL_KEYS = {
"manage", "manage",
"systemd", "systemd",
"install",
"path",
"tool",
"expose", "expose",
"proxy", "proxy",
"caddy", "caddy",

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Annotated, Literal, Union 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] EnvMap = dict[str, str]
@@ -38,7 +38,7 @@ class RunCommand(RunBase):
class RunPython(RunBase): class RunPython(RunBase):
runner: Literal["python"] runner: Literal["python"]
tool: str program: str
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
@@ -102,33 +102,6 @@ class ManageSpec(BaseModel):
systemd: SystemdSpec | None = None 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 # HTTP exposure + proxy
# --------------------- # ---------------------
@@ -206,12 +179,13 @@ class ProgramSpec(BaseModel):
id: str = "" id: str = ""
description: str | None = None description: str | None = None
behavior: str | None = None
source: str | None = None source: str | None = None
stack: str | None = None stack: str | None = None
install: InstallSpec | None = None system_dependencies: list[str] = Field(default_factory=list)
tool: ToolSpec | None = None version: str | None = None
build: BuildSpec | None = None build: BuildSpec | None = None
provides: list[Capability] = Field(default_factory=list) 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" castle_yaml = tmp_path / "castle.yaml"
config = { config = {
"gateway": {"port": 18000}, "gateway": {"port": 18000},
"components": { "programs": {
"test-tool": { "test-tool": {
"description": "Test tool", "description": "Test tool",
"install": { "behavior": "tool",
"path": {"alias": "test-tool"},
},
}, },
}, },
"services": { "services": {
@@ -29,7 +27,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service", "description": "Test service",
"run": { "run": {
"runner": "python", "runner": "python",
"tool": "test-svc", "program": "test-svc",
}, },
"defaults": { "defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")}, "env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},

View File

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

View File

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

View File

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

View File

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

View File

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