From efab2a7893eda00d117e496ea270fc56d958b0f6 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 23 Feb 2026 22:56:18 -0800 Subject: [PATCH] refactor: Update component specifications to use 'program' and 'behavior' attributes, enhancing clarity and consistency across the application --- CLAUDE.md | 2 +- app/src/components/AddComponent.tsx | 11 +-- app/src/components/ComponentFields.tsx | 2 +- app/src/components/ProgramActions.tsx | 101 ++++++++++++----------- app/src/pages/ComponentDetail.tsx | 12 ++- app/src/pages/ServiceDetail.tsx | 4 +- castle-api/src/castle_api/routes.py | 75 +++-------------- castle-api/src/castle_api/tools.py | 14 ++-- castle-api/tests/conftest.py | 15 ++-- castle-api/tests/test_tools.py | 12 +-- castle.yaml | 109 +++++++++---------------- cli/src/castle_cli/commands/create.py | 37 ++------- cli/src/castle_cli/commands/deploy.py | 6 +- cli/src/castle_cli/commands/info.py | 42 ++++------ cli/src/castle_cli/commands/tool.py | 17 ++-- cli/src/castle_cli/manifest.py | 3 - cli/tests/conftest.py | 8 +- cli/tests/test_create.py | 3 +- components/android-backup/uv.lock | 8 ++ core/src/castle_core/config.py | 7 +- core/src/castle_core/manifest.py | 36 ++------ core/tests/conftest.py | 8 +- core/tests/test_config.py | 2 +- core/tests/test_manifest.py | 27 +++--- docs/component-registry.md | 71 +++++++++------- docs/stacks/python-cli.md | 15 ++-- docs/stacks/python-fastapi.md | 4 +- 27 files changed, 258 insertions(+), 393 deletions(-) create mode 100644 components/android-backup/uv.lock diff --git a/CLAUDE.md b/CLAUDE.md index 1e02d6d..8f53aae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/app/src/components/AddComponent.tsx b/app/src/components/AddComponent.tsx index 5b44c13..098a97e 100644 --- a/app/src/components/AddComponent.tsx +++ b/app/src/components/AddComponent.tsx @@ -5,7 +5,7 @@ const TEMPLATES: Record> = { service: { run: { runner: "python", - tool: "", + program: "", cwd: "", env: {}, }, @@ -23,9 +23,7 @@ const TEMPLATES: Record> = { }, }, 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 - run.tool = name + run.program = name run.cwd = name const proxy = (config.proxy as Record>).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>).path - install.alias = name + // tool template has behavior preset, no extra config needed } await onAdd(name, config) diff --git a/app/src/components/ComponentFields.tsx b/app/src/components/ComponentFields.tsx index 82fe120..1ab6683 100644 --- a/app/src/components/ComponentFields.tsx +++ b/app/src/components/ComponentFields.tsx @@ -115,7 +115,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields {runnerLabel(runner)} - {(m.run as Record)?.tool && ( + {(m.run as Record)?.program && ( <> · {(m.run as Record).tool} )} diff --git a/app/src/components/ProgramActions.tsx b/app/src/components/ProgramActions.tsx index d71c2de..4d68618 100644 --- a/app/src/components/ProgramActions.tsx +++ b/app/src/components/ProgramActions.tsx @@ -32,11 +32,18 @@ const ACTION_CONFIG: Record = { 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(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,58 +114,59 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct if (visible.length === 0) return null return ( -
-
- {visible.map((action) => { - const config = ACTION_CONFIG[action] - if (!config) return null - const Icon = config.icon - const isRunning = isPending && runningAction === action - - if (compact) { - return ( - - ) - } +
+ {visible.map((action) => { + const config = ACTION_CONFIG[action] + if (!config) return null + const Icon = config.icon + const isRunning = isPending && runningAction === action + if (compact) { return ( ) - })} -
+ } - {output && !compact && ( -
-
- {ACTION_CONFIG[output.action]?.label ?? output.action} — {output.ok ? "ok" : "error"} - -
-
-            {output.text}
-          
-
- )} + return ( + + ) + })} +
+ ) +} + +export function ActionOutputPanel({ output, onDismiss }: { output: ActionOutput; onDismiss: () => void }) { + if (!output.action) return null + return ( +
+
+ {ACTION_CONFIG[output.action]?.label ?? output.action} — {output.ok ? "ok" : "error"} + +
+
+        {output.text}
+      
) } diff --git a/app/src/pages/ComponentDetail.tsx b/app/src/pages/ComponentDetail.tsx index c0ea822..3e5ffc4 100644 --- a/app/src/pages/ComponentDetail.tsx +++ b/app/src/pages/ComponentDetail.tsx @@ -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(null) if (isLoading) { return ( @@ -37,9 +39,15 @@ export function ComponentDetailPage() { stack={component.stack} source={component.source} > - + + {actionOutput && actionOutput.action && ( +
+ setActionOutput(null)} /> +
+ )} + {component.description && (

{component.description}

)} diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index 15030d1..abf176e 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -89,8 +89,8 @@ export function ServiceDetailPage() { {runnerLabel(runner)} - {(component.manifest.run as Record)?.tool && ( - <> · {(component.manifest.run as Record).tool} + {(component.manifest.run as Record)?.program && ( + <> · {(component.manifest.run as Record).program} )} diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index ca83020..bdc6356 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -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,21 +660,11 @@ 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 + continue summary.node = local_hostname summaries.append(summary) except FileNotFoundError: diff --git a/castle-api/src/castle_api/tools.py b/castle-api/src/castle_api/tools.py index 63fa4b6..30eded6 100644 --- a/castle-api/src/castle_api/tools.py +++ b/castle-api/src/castle_api/tools.py @@ -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, ) diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 4ba8c1e..2e01ac6 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -23,21 +23,18 @@ 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": { - "system_dependencies": ["pandoc"], - }, + "behavior": "tool", + "system_dependencies": ["pandoc"], }, "test-tool-2": { "description": "Another test tool", "source": "test-tool-2", - "tool": { - "version": "2.0.0", - }, + "behavior": "tool", + "version": "2.0.0", }, }, "services": { @@ -46,7 +43,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "description": "Test service", "run": { "runner": "python", - "tool": "test-svc", + "program": "test-svc", }, "expose": { "http": { diff --git a/castle-api/tests/test_tools.py b/castle-api/tests/test_tools.py index 22bf0bf..abdc90d 100644 --- a/castle-api/tests/test_tools.py +++ b/castle-api/tests/test_tools.py @@ -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.""" diff --git a/castle.yaml b/castle.yaml index e7e2c76..972cdae 100644 --- a/castle.yaml +++ b/castle.yaml @@ -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: - system_dependencies: - - rsync + behavior: tool + system_dependencies: + - rsync castle-app: description: Castle web app source: app stack: react-vite + behavior: frontend build: commands: - - pnpm @@ -45,124 +47,89 @@ 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: - system_dependencies: - - adb + 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: - system_dependencies: - - pandoc + behavior: tool + system_dependencies: + - pandoc docx2md: description: Convert Word .docx files to Markdown source: components/docx2md stack: python-cli - install: - path: - alias: docx2md - tool: - system_dependencies: - - pandoc + 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: - system_dependencies: - - pandoc - - texlive-latex-base + behavior: tool + system_dependencies: + - pandoc + - texlive-latex-base mdscraper: 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: - system_dependencies: - - pandoc - - poppler-utils + behavior: tool + system_dependencies: + - pandoc + - poppler-utils schedule: 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: diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index e1d7322..e57963d 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -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 + 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": - # 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( 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) diff --git a/cli/src/castle_cli/commands/deploy.py b/cli/src/castle_cli/commands/deploy.py index 5bcc0af..1845720 100644 --- a/cli/src/castle_cli/commands/deploy.py +++ b/cli/src/castle_cli/commands/deploy.py @@ -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 " ) - cmd = [resolved or run.tool] + cmd = [resolved or run.program] if run.args: cmd.extend(run.args) return cmd diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 60f6714..a4f211d 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -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 diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py index f1d6361..05d785e 100644 --- a/cli/src/castle_cli/commands/tool.py +++ b/cli/src/castle_cli/commands/tool.py @@ -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 diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index 2fa0121..8ba29e4 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -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, ) diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py index 772b72e..ae88936 100644 --- a/cli/tests/conftest.py +++ b/cli/tests/conftest.py @@ -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")}, diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 679f617..4390004 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -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.""" diff --git a/components/android-backup/uv.lock b/components/android-backup/uv.lock new file mode 100644 index 0000000..5b7a2ec --- /dev/null +++ b/components/android-backup/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "android-backup" +version = "0.1.0" +source = { editable = "." } diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 9c2bf5b..6d2c48c 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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", diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 923d1de..4a3c6e7 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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) diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 0f6191e..2a8a360 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -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")}, diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 79f9ad1..1538748 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -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.""" diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index 24eaa4b..75b0c53 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -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" diff --git a/docs/component-registry.md b/docs/component-registry.md index 5f53f61..92a12c1 100644 --- a/docs/component-registry.md +++ b/docs/component-registry.md @@ -16,17 +16,16 @@ programs: my-tool: description: Does something useful source: programs/my-tool - install: - path: { alias: my-tool } - tool: - system_dependencies: [pandoc] + stack: python-cli + behavior: tool + system_dependencies: [pandoc] services: my-service: 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 diff --git a/docs/stacks/python-cli.md b/docs/stacks/python-cli.md index 3086dd3..b064e17 100644 --- a/docs/stacks/python-cli.md +++ b/docs/stacks/python-cli.md @@ -318,23 +318,20 @@ 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: - system_dependencies: [pandoc, poppler-utils] + stack: python-cli + behavior: tool + system_dependencies: [pandoc, poppler-utils] ``` Tools live in the `programs:` section. If a tool also runs on a schedule, diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index 9959253..a581a44 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -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 }