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: