diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 8bf0d2c..801f4ee 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -18,7 +18,6 @@ from castle_cli.manifest import ( RunPythonUvTool, SystemdSpec, ToolSpec, - ToolType, ) from castle_cli.templates.scaffold import scaffold_category_tool, scaffold_project @@ -102,10 +101,7 @@ def run_create(args: argparse.Namespace) -> int: manifest = ComponentManifest( id=name, description=args.description or f"A castle {proj_type}", - tool=ToolSpec( - tool_type=ToolType.PYTHON_STANDALONE, - source=f"{name}/", - ), + tool=ToolSpec(source=f"{name}/"), install=InstallSpec(path=PathInstallSpec(alias=name)), ) else: @@ -173,11 +169,7 @@ def _create_category_tool(config: object, name: str, description: str | None, ca manifest = ComponentManifest( id=name, description=desc, - tool=ToolSpec( - tool_type=ToolType.PYTHON_STANDALONE, - category=category, - source=f"tools/{category}/", - ), + tool=ToolSpec(source=f"tools/{category}/"), install=InstallSpec(path=PathInstallSpec(alias=name)), ) config.components[name] = manifest diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 725bac9..160f575 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -85,7 +85,6 @@ def run_info(args: argparse.Namespace) -> int: # Tool if manifest.tool: t = manifest.tool - print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}") if t.source: print(f" {BOLD}source{RESET}: {t.source}") if t.system_dependencies: diff --git a/cli/src/castle_cli/commands/sync.py b/cli/src/castle_cli/commands/sync.py index c7cd103..3bc275b 100644 --- a/cli/src/castle_cli/commands/sync.py +++ b/cli/src/castle_cli/commands/sync.py @@ -8,7 +8,6 @@ import subprocess from pathlib import Path from castle_cli.config import ensure_dirs, load_config -from castle_cli.manifest import ToolType def run_sync(args: argparse.Namespace) -> int: @@ -46,7 +45,7 @@ def run_sync(args: argparse.Namespace) -> int: print(" OK") synced_dirs.add(project_dir) - # Install tools by type + # Install tools — infer method from project structure uv_path = shutil.which("uv") or "uv" installed_dirs: set[Path] = set() @@ -54,18 +53,19 @@ def run_sync(args: argparse.Namespace) -> int: if not manifest.tool: continue - if manifest.tool.tool_type == ToolType.PYTHON_STANDALONE: - source = manifest.tool.source - if not source: - continue - project_dir = config.root / source - if not (project_dir / "pyproject.toml").exists(): - continue - if project_dir in installed_dirs: + source = manifest.tool.source + if not source: + continue + + source_dir = config.root / source + + if (source_dir / "pyproject.toml").exists(): + # Python package — uv tool install + if source_dir in installed_dirs: continue print(f"\nInstalling {name}...") result = subprocess.run( - [uv_path, "tool", "install", "--editable", str(project_dir), + [uv_path, "tool", "install", "--editable", str(source_dir), "--force"], capture_output=True, text=True, ) @@ -77,23 +77,19 @@ def run_sync(args: argparse.Namespace) -> int: all_ok = False else: print(f" {name}: OK") - installed_dirs.add(project_dir) + installed_dirs.add(source_dir) - elif manifest.tool.tool_type == ToolType.SCRIPT: - # Verify script tools are accessible + elif source_dir.is_file(): + # Script file — symlink to ~/.local/bin/ alias = name if manifest.install and manifest.install.path and manifest.install.path.alias: alias = manifest.install.path.alias if not shutil.which(alias): - source = manifest.tool.source - if source: - script_path = config.root / source - if script_path.exists(): - link = Path.home() / ".local" / "bin" / alias - link.parent.mkdir(parents=True, exist_ok=True) - if not link.exists(): - link.symlink_to(script_path) - print(f"\n Linked {alias} → {script_path}") + link = Path.home() / ".local" / "bin" / alias + link.parent.mkdir(parents=True, exist_ok=True) + if not link.exists(): + link.symlink_to(source_dir) + print(f"\n Linked {alias} → {source_dir}") if all_ok: print("\nAll projects synced.") diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py index 7467ca6..919ae52 100644 --- a/cli/src/castle_cli/commands/tool.py +++ b/cli/src/castle_cli/commands/tool.py @@ -36,14 +36,17 @@ def _tool_list() -> int: print("No tools registered.") return 0 - by_category: dict[str, list[tuple[str, object]]] = {} + by_group: dict[str, list[tuple[str, object]]] = {} for name, manifest in tools.items(): - cat = manifest.tool.category or "uncategorized" - by_category.setdefault(cat, []).append((name, manifest)) + if manifest.tool and manifest.tool.source: + group = Path(manifest.tool.source).name + else: + group = "standalone" + by_group.setdefault(group, []).append((name, manifest)) - for category in sorted(by_category): - items = by_category[category] - print(f"\n{BOLD}{CYAN}{category}{RESET}") + for group in sorted(by_group): + items = by_group[group] + print(f"\n{BOLD}{CYAN}{group}{RESET}") print(f"{CYAN}{'─' * 40}{RESET}") for name, manifest in sorted(items): desc = manifest.description or "" @@ -74,7 +77,6 @@ def _tool_info(name: str) -> int: print(f"{'─' * 40}") if manifest.description: print(f" {manifest.description}") - print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}") print(f" {BOLD}version{RESET}: {t.version}") if t.source: print(f" {BOLD}source{RESET}: {t.source}") @@ -83,7 +85,7 @@ def _tool_info(name: str) -> int: # Read and display .md documentation if available if t.source: - md_path = _find_md_for_tool(config.root, t.source, name, t.category) + md_path = _find_md_for_tool(config.root, t.source, name) if md_path and md_path.exists(): content = md_path.read_text() # Strip YAML frontmatter @@ -102,7 +104,7 @@ def _tool_info(name: str) -> int: def _find_md_for_tool( - root: Path, source: str, tool_name: str, category: str | None = None, + root: Path, source: str, tool_name: str, ) -> Path | None: """Find the .md documentation file for a tool source path.""" source_path = root / source @@ -111,10 +113,9 @@ def _find_md_for_tool( if md.exists(): return md elif source_path.is_dir(): - # Directory source — look for src//.md py_name = tool_name.replace("-", "_") - if category: - md = source_path / "src" / category / f"{py_name}.md" - if md.exists(): - return md + pkg_name = source_path.name + md = source_path / "src" / pkg_name / f"{py_name}.md" + if md.exists(): + return md return None diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index 2cfd2d3..ab554a7 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -5,7 +5,7 @@ from __future__ import annotations from enum import Enum from typing import Annotated, Literal, Union -from pydantic import BaseModel, Field, computed_field, model_validator +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator EnvMap = dict[str, str] @@ -143,6 +143,7 @@ class SystemdSpec(BaseModel): restart_sec: int = 2 no_new_privileges: bool = True readiness: ReadinessHttpGet | None = None + exec_reload: str | None = None class ManageSpec(BaseModel): @@ -169,14 +170,9 @@ class InstallSpec(BaseModel): # --------------------- -class ToolType(str, Enum): - PYTHON_STANDALONE = "python_standalone" - SCRIPT = "script" - - class ToolSpec(BaseModel): - tool_type: ToolType = ToolType.PYTHON_STANDALONE - category: str | None = None + model_config = ConfigDict(extra="ignore") + version: str = "1.0.0" source: str | None = None system_dependencies: list[str] = Field(default_factory=list) diff --git a/cli/tests/test_manifest.py b/cli/tests/test_manifest.py index 877f898..d041529 100644 --- a/cli/tests/test_manifest.py +++ b/cli/tests/test_manifest.py @@ -21,7 +21,6 @@ from castle_cli.manifest import ( RunRemote, SystemdSpec, ToolSpec, - ToolType, TriggerSchedule, ) @@ -96,11 +95,7 @@ class TestRoleDerivation: """Component with tool spec gets TOOL role.""" m = ComponentManifest( id="docx2md", - tool=ToolSpec( - tool_type=ToolType.PYTHON_STANDALONE, - category="document", - source="tools/document/", - ), + tool=ToolSpec(source="tools/document/"), ) assert Role.TOOL in m.roles @@ -108,7 +103,7 @@ class TestRoleDerivation: """Tool spec alone is enough for TOOL role, no install.path needed.""" m = ComponentManifest( id="my-tool", - tool=ToolSpec(category="misc"), + tool=ToolSpec(), ) assert Role.TOOL in m.roles diff --git a/dashboard-api/src/dashboard_api/models.py b/dashboard-api/src/dashboard_api/models.py index 98ceac9..7ecd8a0 100644 --- a/dashboard-api/src/dashboard_api/models.py +++ b/dashboard-api/src/dashboard_api/models.py @@ -3,6 +3,14 @@ from pydantic import BaseModel +class SystemdInfo(BaseModel): + """Systemd unit information for a managed component.""" + + unit_name: str + unit_path: str + timer: bool = False + + class ComponentSummary(BaseModel): """Summary of a single component.""" @@ -14,9 +22,8 @@ class ComponentSummary(BaseModel): health_path: str | None = None proxy_path: str | None = None managed: bool = False - category: str | None = None + systemd: SystemdInfo | None = None version: str | None = None - tool_type: str | None = None source: str | None = None system_dependencies: list[str] = [] schedule: str | None = None @@ -65,9 +72,7 @@ class ToolSummary(BaseModel): id: str description: str | None = None - category: str | None = None source: str | None = None - tool_type: str | None = None version: str | None = None runner: str | None = None system_dependencies: list[str] = [] diff --git a/dashboard-api/src/dashboard_api/routes.py b/dashboard-api/src/dashboard_api/routes.py index 4be4308..b444d8b 100644 --- a/dashboard-api/src/dashboard_api/routes.py +++ b/dashboard-api/src/dashboard_api/routes.py @@ -3,6 +3,7 @@ from __future__ import annotations import shutil +from pathlib import Path from fastapi import APIRouter, HTTPException, status @@ -15,12 +16,13 @@ from dashboard_api.models import ( ComponentSummary, GatewayInfo, StatusResponse, + SystemdInfo, ) router = APIRouter(tags=["dashboard"]) -def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary: +def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary: """Build a ComponentSummary from a manifest.""" port = None health_path = None @@ -35,6 +37,18 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary: manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable ) + # Systemd info for managed components + systemd_info: SystemdInfo | None = None + if managed: + unit_name = f"castle-{name}.service" + unit_path = str(Path("~/.config/systemd/user") / unit_name) + has_timer = any(getattr(t, "type", None) == "schedule" for t in manifest.triggers) + systemd_info = SystemdInfo( + unit_name=unit_name, + unit_path=unit_path, + timer=has_timer, + ) + # Extract cron schedule from first schedule trigger, if any schedule = None for t in manifest.triggers: @@ -42,6 +56,15 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary: schedule = t.cron break + # Infer runner — from run block or from tool source + runner = manifest.run.runner if manifest.run else None + if runner is None and manifest.tool and manifest.tool.source: + source_dir = root / manifest.tool.source + if (source_dir / "pyproject.toml").exists(): + runner = "python_uv_tool" + elif source_dir.is_file(): + runner = "command" + # Check if tool is actually installed on PATH installed: bool | None = None if manifest.install and manifest.install.path: @@ -52,14 +75,13 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary: id=name, description=manifest.description, roles=[r.value for r in manifest.roles], - runner=manifest.run.runner if manifest.run else None, + runner=runner, port=port, health_path=health_path, proxy_path=proxy_path, managed=managed, - category=manifest.tool.category if manifest.tool else None, + systemd=systemd_info, version=manifest.tool.version if manifest.tool else None, - tool_type=manifest.tool.tool_type.value if manifest.tool else None, source=manifest.tool.source if manifest.tool else None, system_dependencies=manifest.tool.system_dependencies if manifest.tool else [], schedule=schedule, @@ -72,7 +94,7 @@ def list_components() -> list[ComponentSummary]: """List all registered components.""" config = load_config(settings.castle_root) return [ - _summary_from_manifest(name, m) + _summary_from_manifest(name, m, config.root) for name, m in config.components.items() ] @@ -87,7 +109,7 @@ def get_component(name: str) -> ComponentDetail: detail=f"Component '{name}' not found", ) manifest = config.components[name] - summary = _summary_from_manifest(name, manifest) + summary = _summary_from_manifest(name, manifest, config.root) raw = manifest.model_dump(mode="json", exclude_none=True) return ComponentDetail(**summary.model_dump(), manifest=raw) diff --git a/dashboard-api/src/dashboard_api/tools.py b/dashboard-api/src/dashboard_api/tools.py index 2367cbb..c66ac1b 100644 --- a/dashboard-api/src/dashboard_api/tools.py +++ b/dashboard-api/src/dashboard_api/tools.py @@ -16,19 +16,27 @@ from dashboard_api.models import ToolCategory, ToolDetail, ToolSummary router = APIRouter(tags=["tools"]) -def _tool_summary(name: str, manifest: ComponentManifest) -> ToolSummary: +def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = None) -> ToolSummary: """Build a ToolSummary from a manifest that has a tool spec.""" t = manifest.tool assert t is not None installed = bool(manifest.install and manifest.install.path and manifest.install.path.enable) + + # Infer runner from run block or source directory + runner = manifest.run.runner if manifest.run else None + if runner is None and t.source and root: + source_dir = root / t.source + if (source_dir / "pyproject.toml").exists(): + runner = "python_uv_tool" + elif source_dir.is_file(): + runner = "command" + return ToolSummary( id=name, description=manifest.description, - category=t.category, source=t.source, - tool_type=t.tool_type.value, version=t.version, - runner=manifest.run.runner if manifest.run else None, + runner=runner, system_dependencies=t.system_dependencies, installed=installed, ) @@ -38,7 +46,6 @@ def _find_md_for_tool( root: Path, source: str, tool_name: str, - category: str | None = None, ) -> Path | None: """Find the .md documentation file for a tool source path.""" source_path = root / source @@ -48,10 +55,10 @@ def _find_md_for_tool( return md elif source_path.is_dir(): py_name = tool_name.replace("-", "_") - if category: - md = source_path / "src" / category / f"{py_name}.md" - if md.exists(): - return md + pkg_name = source_path.name + md = source_path / "src" / pkg_name / f"{py_name}.md" + if md.exists(): + return md return None @@ -66,18 +73,23 @@ def _strip_frontmatter(content: str) -> str: @router.get("/tools", response_model=list[ToolCategory]) def list_tools() -> list[ToolCategory]: - """List tools grouped by category.""" + """List tools grouped by source directory.""" config = load_config(settings.castle_root) tools = {k: v for k, v in config.components.items() if v.tool} - by_category: dict[str, list[ToolSummary]] = {} + by_group: dict[str, list[ToolSummary]] = {} for name, manifest in tools.items(): - cat = manifest.tool.category or "uncategorized" # type: ignore[union-attr] - by_category.setdefault(cat, []).append(_tool_summary(name, manifest)) + t = manifest.tool + assert t is not None + if t.source: + group = Path(t.source).name + else: + group = "standalone" + by_group.setdefault(group, []).append(_tool_summary(name, manifest, config.root)) return [ - ToolCategory(name=cat, tools=sorted(items, key=lambda t: t.id)) - for cat, items in sorted(by_category.items()) + ToolCategory(name=group, tools=sorted(items, key=lambda t: t.id)) + for group, items in sorted(by_group.items()) ] @@ -99,11 +111,11 @@ def get_tool(name: str) -> ToolDetail: detail=f"'{name}' is not a tool", ) - summary = _tool_summary(name, manifest) + summary = _tool_summary(name, manifest, config.root) docs: str | None = None t = manifest.tool if t.source: - md_path = _find_md_for_tool(config.root, t.source, name, t.category) + md_path = _find_md_for_tool(config.root, t.source, name) if md_path and md_path.exists(): docs = _strip_frontmatter(md_path.read_text()) if not docs: diff --git a/dashboard-api/tests/conftest.py b/dashboard-api/tests/conftest.py index 7c50c67..d1bcb6a 100644 --- a/dashboard-api/tests/conftest.py +++ b/dashboard-api/tests/conftest.py @@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "description": "Test tool", "install": {"path": {"alias": "test-tool"}}, "tool": { - "category": "document", "source": "tools/document", "system_dependencies": ["pandoc"], }, @@ -46,9 +45,8 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "test-tool-2": { "description": "Another test tool", "tool": { - "category": "utility", + "source": "tools/utility", "version": "2.0.0", - "tool_type": "script", }, }, }, diff --git a/dashboard-api/tests/test_tools.py b/dashboard-api/tests/test_tools.py index c5013a2..053ef71 100644 --- a/dashboard-api/tests/test_tools.py +++ b/dashboard-api/tests/test_tools.py @@ -9,7 +9,7 @@ class TestToolsList: """GET /tools endpoint tests.""" def test_returns_grouped_tools(self, client: TestClient) -> None: - """Returns tools grouped by category.""" + """Returns tools grouped by source directory name.""" response = client.get("/tools") assert response.status_code == 200 data = response.json() @@ -17,8 +17,8 @@ class TestToolsList: assert "document" in names assert "utility" in names - def test_categories_sorted(self, client: TestClient) -> None: - """Categories are sorted alphabetically.""" + def test_groups_sorted(self, client: TestClient) -> None: + """Groups are sorted alphabetically.""" response = client.get("/tools") data = response.json() names = [cat["name"] for cat in data] @@ -28,27 +28,29 @@ class TestToolsList: """Tool summary has expected fields.""" response = client.get("/tools") data = response.json() - doc_cat = next(c for c in data if c["name"] == "document") - tool = next(t for t in doc_cat["tools"] if t["id"] == "test-tool") + doc_group = next(c for c in data if c["name"] == "document") + tool = next(t for t in doc_group["tools"] if t["id"] == "test-tool") assert tool["description"] == "Test tool" - assert tool["category"] == "document" assert tool["source"] == "tools/document" assert tool["system_dependencies"] == ["pandoc"] + # tool_type and category should not be present + assert "tool_type" not in tool + assert "category" not in tool def test_installed_flag(self, client: TestClient) -> None: """Tool with install.path is marked as installed.""" response = client.get("/tools") data = response.json() - doc_cat = next(c for c in data if c["name"] == "document") - tool = next(t for t in doc_cat["tools"] if t["id"] == "test-tool") + doc_group = next(c for c in data if c["name"] == "document") + tool = next(t for t in doc_group["tools"] 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() - util_cat = next(c for c in data if c["name"] == "utility") - tool = next(t for t in util_cat["tools"] if t["id"] == "test-tool-2") + util_group = next(c for c in data if c["name"] == "utility") + tool = next(t for t in util_group["tools"] if t["id"] == "test-tool-2") assert tool["installed"] is False def test_service_excluded(self, client: TestClient) -> None: @@ -68,12 +70,12 @@ class TestToolDetail: assert response.status_code == 200 data = response.json() assert data["id"] == "test-tool" - assert data["category"] == "document" + assert data["source"] == "tools/document" assert data["system_dependencies"] == ["pandoc"] def test_docs_from_file(self, client: TestClient, castle_root: Path) -> None: """Reads documentation from .md file.""" - # Create doc file matching the source lookup + # Create doc file matching the source lookup (src//.md) doc_dir = castle_root / "tools" / "document" / "src" / "document" doc_dir.mkdir(parents=True) (doc_dir / "test_tool.md").write_text( diff --git a/dashboard/src/components/ComponentTable.tsx b/dashboard/src/components/ComponentTable.tsx index e760027..eaeef59 100644 --- a/dashboard/src/components/ComponentTable.tsx +++ b/dashboard/src/components/ComponentTable.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom" import { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react" import type { ComponentSummary, HealthStatus } from "@/types" import { useServiceAction, useToolAction } from "@/services/api/hooks" -import { runnerLabel, toolTypeLabel } from "@/lib/labels" +import { runnerLabel } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" import { RoleBadge } from "./RoleBadge" @@ -12,7 +12,7 @@ interface ComponentTableProps { statuses: HealthStatus[] } -type SortKey = "id" | "role" | "runner" | "tool_type" | "category" | "schedule" | "port" | "status" +type SortKey = "id" | "role" | "runner" | "schedule" | "port" | "status" type SortDir = "asc" | "desc" function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { @@ -76,10 +76,6 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "") case "runner": return dir * (a.runner ?? "").localeCompare(b.runner ?? "") - case "tool_type": - return dir * (a.tool_type ?? "").localeCompare(b.tool_type ?? "") - case "category": - return dir * (a.category ?? "").localeCompare(b.category ?? "") case "schedule": return dir * (a.schedule ?? "").localeCompare(b.schedule ?? "") case "port": @@ -137,8 +133,6 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { - - @@ -155,7 +149,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { ))} {sorted.length === 0 && ( - + No components match. @@ -228,6 +222,7 @@ function ComponentRow({ {component.id} @@ -247,12 +242,6 @@ function ComponentRow({ {component.runner ? runnerLabel(component.runner) : "—"} - - {component.tool_type ? toolTypeLabel(component.tool_type) : "—"} - - - {component.category ?? "—"} - {component.schedule ?? "—"} diff --git a/dashboard/src/lib/labels.ts b/dashboard/src/lib/labels.ts index 68cfaf4..3251f6b 100644 --- a/dashboard/src/lib/labels.ts +++ b/dashboard/src/lib/labels.ts @@ -7,11 +7,6 @@ export const RUNNER_LABELS: Record = { remote: "Remote", } -export const TOOL_TYPE_LABELS: Record = { - python_standalone: "Python package", - script: "Shell script", -} - export const ROLE_DESCRIPTIONS: Record = { service: "Exposes HTTP endpoints", tool: "CLI utility installed to PATH", @@ -25,7 +20,3 @@ export const ROLE_DESCRIPTIONS: Record = { export function runnerLabel(runner: string): string { return RUNNER_LABELS[runner] ?? runner } - -export function toolTypeLabel(toolType: string): string { - return TOOL_TYPE_LABELS[toolType] ?? toolType -} diff --git a/dashboard/src/pages/ComponentDetail.tsx b/dashboard/src/pages/ComponentDetail.tsx index 78488e2..03f73fc 100644 --- a/dashboard/src/pages/ComponentDetail.tsx +++ b/dashboard/src/pages/ComponentDetail.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react" import { useQueryClient } from "@tanstack/react-query" import { apiClient } from "@/services/api/client" import { useComponent, useStatus, useServiceAction, useEventStream, useToolDetail } from "@/services/api/hooks" -import { runnerLabel, toolTypeLabel } from "@/lib/labels" +import { runnerLabel } from "@/lib/labels" import { ComponentFields } from "@/components/ComponentFields" import { HealthBadge } from "@/components/HealthBadge" import { LogViewer } from "@/components/LogViewer" @@ -142,12 +142,6 @@ export function ComponentDetailPage() { How this tool is packaged and what it depends on.

- {toolDetail.category && ( - <> - Category - {toolDetail.category} - - )} {toolDetail.source && ( <> Source @@ -160,12 +154,6 @@ export function ComponentDetailPage() { {toolDetail.version} )} - {toolDetail.tool_type && ( - <> - Type - {toolTypeLabel(toolDetail.tool_type)} - - )} {toolDetail.runner && ( <> Runner @@ -199,6 +187,26 @@ export function ComponentDetailPage() {
)} + {component.systemd && ( +
+

+ Systemd +

+
+ Unit + {component.systemd.unit_name} + Path + {component.systemd.unit_path} + {component.systemd.timer && ( + <> + Timer + Active + + )} +
+
+ )} +

Configuration diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index 00dd44d..dd98573 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -1,3 +1,9 @@ +export interface SystemdInfo { + unit_name: string + unit_path: string + timer: boolean +} + export interface ComponentSummary { id: string description: string | null @@ -7,9 +13,8 @@ export interface ComponentSummary { health_path: string | null proxy_path: string | null managed: boolean - category: string | null + systemd: SystemdInfo | null version: string | null - tool_type: string | null source: string | null system_dependencies: string[] schedule: string | null @@ -57,9 +62,7 @@ export interface SSEServiceActionEvent { export interface ToolSummary { id: string description: string | null - category: string | null source: string | null - tool_type: string | null version: string | null runner: string | null system_dependencies: string[] diff --git a/docs/component-registry.md b/docs/component-registry.md index bd37ebb..d432561 100644 --- a/docs/component-registry.md +++ b/docs/component-registry.md @@ -132,8 +132,6 @@ Creates a shim so the tool is available system-wide after ```yaml tool: - tool_type: python_standalone # or "script" - category: document # Grouping for display source: tools/document/ # Source directory version: "1.0.0" system_dependencies: [pandoc, poppler-utils] @@ -143,6 +141,11 @@ This block provides metadata for `castle tool list` and the dashboard. It's separate from `install` (which handles PATH registration) and `run` (which handles execution). +The install method (uv tool install vs symlink) is inferred from the source +directory: if `pyproject.toml` exists, it's a Python package; if the source +is a file, it's symlinked. Grouping for `castle tool list` and the dashboard +is derived from the source directory name (e.g., `tools/document/` → "document"). + ### `build` — How to build it ```yaml @@ -223,8 +226,6 @@ components: my-tool: description: Does something useful tool: - tool_type: python_standalone - category: utilities source: my-tool/ install: path: diff --git a/docs/python-tools.md b/docs/python-tools.md index 4fcfbf0..f91a5ed 100644 --- a/docs/python-tools.md +++ b/docs/python-tools.md @@ -375,8 +375,6 @@ components: my-tool: description: Does something useful tool: - tool_type: python_standalone - category: utilities source: my-tool/ install: path: @@ -390,8 +388,6 @@ components: pdf2md: description: Convert PDF files to Markdown tool: - tool_type: python_standalone - category: document source: tools/document/ system_dependencies: [pandoc, poppler-utils] install: