feat: Enhance tool management and documentation in Castle
- Introduced category tools support in the `castle create` command. - Added detailed guides for creating components in CLAUDE.md. - Implemented new API endpoints for listing and retrieving tool details. - Updated component and tool models to include additional metadata. - Improved error handling and response structures in service actions. - Enhanced documentation for component registry and web APIs.
This commit is contained in:
106
CLAUDE.md
106
CLAUDE.md
@@ -14,6 +14,37 @@ are **derived**, not labeled.
|
|||||||
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway,
|
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway,
|
||||||
event bus) know about castle internals.
|
event bus) know about castle internals.
|
||||||
|
|
||||||
|
## Creating Components
|
||||||
|
|
||||||
|
When creating a new service, tool, or frontend, follow the detailed guides:
|
||||||
|
|
||||||
|
- @docs/component-registry.md — Manifest architecture, castle.yaml structure, role derivation, lifecycle
|
||||||
|
- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing)
|
||||||
|
- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
||||||
|
- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Service
|
||||||
|
castle create my-service --type service --description "Does something"
|
||||||
|
cd my-service && uv sync
|
||||||
|
uv run my-service # starts on auto-assigned port
|
||||||
|
castle service enable my-service # register with systemd
|
||||||
|
castle gateway reload # update reverse proxy routes
|
||||||
|
|
||||||
|
# Standalone tool
|
||||||
|
castle create my-tool --type tool --description "Does something"
|
||||||
|
cd my-tool && uv sync
|
||||||
|
|
||||||
|
# Category tool (adds to existing tools/<category>/ package)
|
||||||
|
castle create my-tool --type tool --category document --description "Does something"
|
||||||
|
cd tools/document && uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
The `castle create` command scaffolds the project, generates a CLAUDE.md,
|
||||||
|
and registers it in `castle.yaml` as a `ComponentManifest`.
|
||||||
|
|
||||||
## Castle CLI
|
## Castle CLI
|
||||||
|
|
||||||
The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
||||||
@@ -28,59 +59,14 @@ castle lint [project] # Run linter (one or all)
|
|||||||
castle sync # Update submodules + uv sync all
|
castle sync # Update submodules + uv sync all
|
||||||
castle run <component> # Run component in foreground
|
castle run <component> # Run component in foreground
|
||||||
castle logs <component> [-f] [-n 50] # View component logs
|
castle logs <component> [-f] [-n 50] # View component logs
|
||||||
|
castle tool list # List tools grouped by category
|
||||||
|
castle tool info <name> # Show tool details + docs
|
||||||
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
||||||
castle service enable|disable <name> # Manage individual systemd service
|
castle service enable|disable <name> # Manage individual systemd service
|
||||||
castle service status # Show all service statuses
|
castle service status # Show all service statuses
|
||||||
castle services start|stop # Start/stop everything
|
castle services start|stop # Start/stop everything
|
||||||
castle migrate # Convert castle.yaml to new format
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Registry & Manifest Architecture
|
|
||||||
|
|
||||||
`castle.yaml` at the repo root is the single source of truth. It uses a **manifest**
|
|
||||||
model (`cli/src/castle_cli/manifest.py`) where components declare capabilities:
|
|
||||||
|
|
||||||
- **`run`**: How to start it (RunSpec: `python_uv_tool`, `command`, `container`, `node`, `remote`)
|
|
||||||
- **`expose`**: What it exposes (HTTP port, health endpoint)
|
|
||||||
- **`proxy`**: How to proxy it (Caddy path prefix)
|
|
||||||
- **`manage`**: How to manage it (systemd)
|
|
||||||
- **`install`**: How to install it (PATH shim)
|
|
||||||
- **`build`**: How to build it (commands, outputs)
|
|
||||||
- **`triggers`**: What triggers it (manual, schedule, event, request)
|
|
||||||
|
|
||||||
**Roles are derived** from these declarations:
|
|
||||||
- `service` — has `expose.http`
|
|
||||||
- `tool` — has `install.path` or is fallback
|
|
||||||
- `worker` — has `manage.systemd` but no HTTP
|
|
||||||
- `job` — has schedule trigger
|
|
||||||
- `frontend` — has build outputs
|
|
||||||
- `containerized` — uses container runner
|
|
||||||
- `remote` — uses remote runner
|
|
||||||
|
|
||||||
## Component Roles (replaces Project Types)
|
|
||||||
|
|
||||||
| Role | Convention | Example |
|
|
||||||
|------|-----------|---------|
|
|
||||||
| **service** | FastAPI, pydantic-settings, lifespan, `/health` endpoint | central-context |
|
|
||||||
| **tool** | argparse, stdin/stdout, exit codes, Unix pipes | devbox-connect |
|
|
||||||
| **worker** | Systemd-managed, no HTTP | (none yet) |
|
|
||||||
| **job** | Scheduled task | (none yet) |
|
|
||||||
| **containerized** | Docker/Podman container | (none yet) |
|
|
||||||
|
|
||||||
## Creating a New Project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
castle create my-service --type service --description "Does something"
|
|
||||||
cd my-service
|
|
||||||
uv sync
|
|
||||||
uv run my-service # starts on auto-assigned port
|
|
||||||
castle test my-service # run tests
|
|
||||||
castle service enable my-service # register with systemd
|
|
||||||
```
|
|
||||||
|
|
||||||
The `castle create` command scaffolds the project, generates a CLAUDE.md, and registers
|
|
||||||
it in `castle.yaml` as a `ComponentManifest`.
|
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
- **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml`
|
- **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml`
|
||||||
@@ -102,18 +88,6 @@ uv run ruff format . # Format
|
|||||||
|
|
||||||
Services also support: `uv run <service-name>` to start.
|
Services also support: `uv run <service-name>` to start.
|
||||||
|
|
||||||
## Existing Components
|
|
||||||
|
|
||||||
| Component | Roles | Port | Description |
|
|
||||||
|-----------|-------|------|-------------|
|
|
||||||
| central-context | service | 9001 | Content storage API (submodule) |
|
|
||||||
| notification-bridge | service | 9002 | Desktop notification forwarder (submodule) |
|
|
||||||
| devbox-connect | tool | — | SSH tunnel manager |
|
|
||||||
| mboxer | tool | — | MBOX to EML converter (submodule) |
|
|
||||||
| toolkit | tool | — | Personal utility scripts (submodule) |
|
|
||||||
| protonmail | tool | — | ProtonMail email sync via Bridge |
|
|
||||||
| event-bus | service | 9010 | Inter-service event bus |
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- **Linting/formatting**: ruff — shared `ruff.toml` at repo root (100-char lines)
|
- **Linting/formatting**: ruff — shared `ruff.toml` at repo root (100-char lines)
|
||||||
@@ -121,11 +95,11 @@ Services also support: `uv run <service-name>` to start.
|
|||||||
- **Testing**: pytest, pytest-asyncio for async tests
|
- **Testing**: pytest, pytest-asyncio for async tests
|
||||||
- **Python**: 3.13 for services, 3.11+ minimum for tools/libraries
|
- **Python**: 3.13 for services, 3.11+ minimum for tools/libraries
|
||||||
|
|
||||||
## Agent Workflow
|
## Key Files
|
||||||
|
|
||||||
When creating a new service or tool:
|
- `castle.yaml` — Component registry (single source of truth)
|
||||||
1. `castle create <name> --type <type>` — scaffold and register
|
- `cli/src/castle_cli/manifest.py` — Pydantic models (ComponentManifest, RunSpec, etc.)
|
||||||
2. Implement the project logic
|
- `cli/src/castle_cli/config.py` — Config loader (castle.yaml → CastleConfig)
|
||||||
3. `castle test <name>` — verify tests pass
|
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates
|
||||||
4. `castle service enable <name>` — deploy as systemd service (services only)
|
- `cli/src/castle_cli/commands/service.py` — Systemd unit generation
|
||||||
5. `castle gateway reload` — update reverse proxy routes
|
- `ruff.toml` / `pyrightconfig.json` — Shared lint/type config
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ from castle_cli.manifest import (
|
|||||||
ProxySpec,
|
ProxySpec,
|
||||||
RunPythonUvTool,
|
RunPythonUvTool,
|
||||||
SystemdSpec,
|
SystemdSpec,
|
||||||
|
ToolSpec,
|
||||||
|
ToolType,
|
||||||
)
|
)
|
||||||
from castle_cli.templates.scaffold import scaffold_project
|
from castle_cli.templates.scaffold import scaffold_category_tool, scaffold_project
|
||||||
|
|
||||||
|
|
||||||
def next_available_port(config: object) -> int:
|
def next_available_port(config: object) -> int:
|
||||||
@@ -41,11 +43,16 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
name = args.name
|
name = args.name
|
||||||
proj_type = args.type
|
proj_type = args.type
|
||||||
|
category = getattr(args, "category", None)
|
||||||
|
|
||||||
if name in config.components:
|
if name in config.components:
|
||||||
print(f"Error: component '{name}' already exists in castle.yaml")
|
print(f"Error: component '{name}' already exists in castle.yaml")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Category tool: add to existing category package
|
||||||
|
if proj_type == "tool" and category:
|
||||||
|
return _create_category_tool(config, name, args.description, category)
|
||||||
|
|
||||||
project_dir = config.root / name
|
project_dir = config.root / name
|
||||||
if project_dir.exists():
|
if project_dir.exists():
|
||||||
print(f"Error: directory '{name}' already exists")
|
print(f"Error: directory '{name}' already exists")
|
||||||
@@ -95,6 +102,10 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
manifest = ComponentManifest(
|
manifest = ComponentManifest(
|
||||||
id=name,
|
id=name,
|
||||||
description=args.description or f"A castle {proj_type}",
|
description=args.description or f"A castle {proj_type}",
|
||||||
|
tool=ToolSpec(
|
||||||
|
tool_type=ToolType.PYTHON_STANDALONE,
|
||||||
|
source=f"{name}/",
|
||||||
|
),
|
||||||
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -119,3 +130,65 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
print(f" castle test {name}")
|
print(f" castle test {name}")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _create_category_tool(config: object, name: str, description: str | None, category: str) -> int:
|
||||||
|
"""Create a tool inside an existing category package."""
|
||||||
|
category_dir = config.root / "tools" / category
|
||||||
|
if not category_dir.exists():
|
||||||
|
print(f"Error: category directory 'tools/{category}/' does not exist")
|
||||||
|
print(" Create the category package first, then add tools to it.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
package_name = name.replace("-", "_")
|
||||||
|
desc = description or "A castle tool"
|
||||||
|
|
||||||
|
# Scaffold the .py and .md files into the category package
|
||||||
|
scaffold_category_tool(
|
||||||
|
category_dir=category_dir,
|
||||||
|
tool_name=name,
|
||||||
|
package_name=package_name,
|
||||||
|
category=category,
|
||||||
|
description=desc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append entry point to existing pyproject.toml
|
||||||
|
pyproject_path = category_dir / "pyproject.toml"
|
||||||
|
if pyproject_path.exists():
|
||||||
|
content = pyproject_path.read_text()
|
||||||
|
entry_line = f'{name} = "{category}.{package_name}:main"'
|
||||||
|
if entry_line not in content:
|
||||||
|
# Find [project.scripts] section and append
|
||||||
|
if "[project.scripts]" in content:
|
||||||
|
content = content.replace(
|
||||||
|
"[project.scripts]",
|
||||||
|
f"[project.scripts]\n{entry_line}",
|
||||||
|
)
|
||||||
|
pyproject_path.write_text(content)
|
||||||
|
print(f" Added entry point to tools/{category}/pyproject.toml")
|
||||||
|
else:
|
||||||
|
print(" Warning: could not find [project.scripts] in pyproject.toml")
|
||||||
|
|
||||||
|
# Register in castle.yaml
|
||||||
|
manifest = ComponentManifest(
|
||||||
|
id=name,
|
||||||
|
description=desc,
|
||||||
|
tool=ToolSpec(
|
||||||
|
tool_type=ToolType.PYTHON_STANDALONE,
|
||||||
|
category=category,
|
||||||
|
source=f"tools/{category}/",
|
||||||
|
),
|
||||||
|
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
||||||
|
)
|
||||||
|
config.components[name] = manifest
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print(f"Created tool '{name}' in tools/{category}/")
|
||||||
|
print(f" Source: tools/{category}/src/{category}/{package_name}.py")
|
||||||
|
print(" Registered in castle.yaml")
|
||||||
|
print("\nNext steps:")
|
||||||
|
print(f" Edit tools/{category}/src/{category}/{package_name}.py")
|
||||||
|
print(f" cd tools/{category} && uv sync")
|
||||||
|
print(f" castle tool info {name}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
pi = manifest.install.path
|
pi = manifest.install.path
|
||||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||||
|
|
||||||
# Tags
|
# Tags
|
||||||
if manifest.tags:
|
if manifest.tags:
|
||||||
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
|
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ WorkingDirectory={working_dir}
|
|||||||
ExecStart={exec_start}
|
ExecStart={exec_start}
|
||||||
{env_lines}Restart={restart}
|
{env_lines}Restart={restart}
|
||||||
RestartSec={restart_sec}
|
RestartSec={restart_sec}
|
||||||
|
SuccessExitStatus=143
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if sd and sd.no_new_privileges:
|
if sd and sd.no_new_privileges:
|
||||||
|
|||||||
@@ -46,13 +46,11 @@ def _tool_list() -> int:
|
|||||||
print(f"\n{BOLD}{CYAN}{category}{RESET}")
|
print(f"\n{BOLD}{CYAN}{category}{RESET}")
|
||||||
print(f"{CYAN}{'─' * 40}{RESET}")
|
print(f"{CYAN}{'─' * 40}{RESET}")
|
||||||
for name, manifest in sorted(items):
|
for name, manifest in sorted(items):
|
||||||
tt = manifest.tool.tool_type.value
|
|
||||||
ver = manifest.tool.version
|
|
||||||
desc = manifest.description or ""
|
desc = manifest.description or ""
|
||||||
deps = ""
|
deps = ""
|
||||||
if manifest.tool.system_dependencies:
|
if manifest.tool.system_dependencies:
|
||||||
deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}"
|
deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}"
|
||||||
print(f" {BOLD}{name:<20}{RESET} {ver:<6} {tt:<20} {desc}{deps}")
|
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
return 0
|
return 0
|
||||||
@@ -76,13 +74,10 @@ def _tool_info(name: str) -> int:
|
|||||||
print(f"{'─' * 40}")
|
print(f"{'─' * 40}")
|
||||||
if manifest.description:
|
if manifest.description:
|
||||||
print(f" {manifest.description}")
|
print(f" {manifest.description}")
|
||||||
print(f" {BOLD}type{RESET}: {t.tool_type.value}")
|
|
||||||
print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}")
|
print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}")
|
||||||
print(f" {BOLD}version{RESET}: {t.version}")
|
print(f" {BOLD}version{RESET}: {t.version}")
|
||||||
if t.source:
|
if t.source:
|
||||||
print(f" {BOLD}source{RESET}: {t.source}")
|
print(f" {BOLD}source{RESET}: {t.source}")
|
||||||
if t.entry_point:
|
|
||||||
print(f" {BOLD}entry{RESET}: {t.entry_point}")
|
|
||||||
if t.system_dependencies:
|
if t.system_dependencies:
|
||||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||||
|
|
||||||
@@ -106,7 +101,9 @@ def _tool_info(name: str) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _find_md_for_tool(root: Path, source: str, tool_name: str, category: str | None = None) -> Path | None:
|
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."""
|
"""Find the .md documentation file for a tool source path."""
|
||||||
source_path = root / source
|
source_path = root / source
|
||||||
if source_path.is_file():
|
if source_path.is_file():
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
create_parser.add_argument(
|
create_parser.add_argument(
|
||||||
"--port", type=int, help="Port number (services only)"
|
"--port", type=int, help="Port number (services only)"
|
||||||
)
|
)
|
||||||
|
create_parser.add_argument(
|
||||||
|
"--category", default=None,
|
||||||
|
help="Tool category (e.g. document, search, system)",
|
||||||
|
)
|
||||||
|
|
||||||
# castle info
|
# castle info
|
||||||
info_parser = subparsers.add_parser("info", help="Show component details")
|
info_parser = subparsers.add_parser("info", help="Show component details")
|
||||||
|
|||||||
@@ -170,17 +170,15 @@ class InstallSpec(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ToolType(str, Enum):
|
class ToolType(str, Enum):
|
||||||
PYTHON_UV = "python_uv"
|
|
||||||
PYTHON_STANDALONE = "python_standalone"
|
PYTHON_STANDALONE = "python_standalone"
|
||||||
SCRIPT = "script"
|
SCRIPT = "script"
|
||||||
|
|
||||||
|
|
||||||
class ToolSpec(BaseModel):
|
class ToolSpec(BaseModel):
|
||||||
tool_type: ToolType = ToolType.PYTHON_UV
|
tool_type: ToolType = ToolType.PYTHON_STANDALONE
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.0"
|
||||||
source: str | None = None
|
source: str | None = None
|
||||||
entry_point: str | None = None
|
|
||||||
system_dependencies: list[str] = Field(default_factory=list)
|
system_dependencies: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,99 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold_category_tool(
|
||||||
|
category_dir: Path,
|
||||||
|
tool_name: str,
|
||||||
|
package_name: str,
|
||||||
|
category: str,
|
||||||
|
description: str,
|
||||||
|
) -> None:
|
||||||
|
"""Scaffold a tool inside an existing category package.
|
||||||
|
|
||||||
|
Creates the .py and .md files only — the category package
|
||||||
|
(pyproject.toml, tests, CLAUDE.md) already exists.
|
||||||
|
"""
|
||||||
|
src_dir = category_dir / "src" / category
|
||||||
|
|
||||||
|
# tool .py file
|
||||||
|
_write(
|
||||||
|
src_dir / f"{package_name}.py",
|
||||||
|
f'''#!/usr/bin/env python3
|
||||||
|
"""{tool_name}: {description}
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{tool_name} [options] [input]
|
||||||
|
cat input.txt | {tool_name}
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
{tool_name} input.txt
|
||||||
|
{tool_name} input.txt -o output.txt
|
||||||
|
cat input.txt | {tool_name} > output.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="{description}",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("input", nargs="?", help="Input file (default: stdin)")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output", default=None, help="Output file (default: stdout)"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.input:
|
||||||
|
with open(args.input) as f:
|
||||||
|
data = f.read()
|
||||||
|
else:
|
||||||
|
data = sys.stdin.read()
|
||||||
|
|
||||||
|
# TODO: implement tool logic
|
||||||
|
result = data
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w") as f:
|
||||||
|
f.write(result)
|
||||||
|
else:
|
||||||
|
print(result, end="")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
|
||||||
|
# tool .md documentation
|
||||||
|
_write(
|
||||||
|
src_dir / f"{package_name}.md",
|
||||||
|
f"""---
|
||||||
|
name: {tool_name}
|
||||||
|
category: {category}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {tool_name}
|
||||||
|
|
||||||
|
{description}
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{tool_name} [options] [input]
|
||||||
|
cat input.txt | {tool_name}
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def scaffold_project(
|
def scaffold_project(
|
||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -97,10 +97,9 @@ class TestRoleDerivation:
|
|||||||
m = ComponentManifest(
|
m = ComponentManifest(
|
||||||
id="docx2md",
|
id="docx2md",
|
||||||
tool=ToolSpec(
|
tool=ToolSpec(
|
||||||
tool_type=ToolType.PYTHON_UV,
|
tool_type=ToolType.PYTHON_STANDALONE,
|
||||||
category="document",
|
category="document",
|
||||||
source="tools/document/docx2md.py",
|
source="tools/document/",
|
||||||
entry_point="tools.document.docx2md:main",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert Role.TOOL in m.roles
|
assert Role.TOOL in m.roles
|
||||||
|
|||||||
@@ -19,16 +19,36 @@ from dashboard_api.logs import router as logs_router
|
|||||||
from dashboard_api.routes import router as dashboard_router
|
from dashboard_api.routes import router as dashboard_router
|
||||||
from dashboard_api.secrets import router as secrets_router
|
from dashboard_api.secrets import router as secrets_router
|
||||||
from dashboard_api.services import router as services_router
|
from dashboard_api.services import router as services_router
|
||||||
from dashboard_api.stream import health_poll_loop, subscribe, unsubscribe
|
from dashboard_api.stream import close_all_subscribers, health_poll_loop, subscribe, unsubscribe
|
||||||
|
from dashboard_api.tools import router as tools_router
|
||||||
|
|
||||||
|
# Set by _watch_shutdown when uvicorn begins its shutdown sequence.
|
||||||
|
_shutting_down = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _watch_shutdown(server: uvicorn.Server) -> None:
|
||||||
|
"""Poll uvicorn's should_exit flag and close SSE subscribers promptly."""
|
||||||
|
while not server.should_exit:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
global _shutting_down
|
||||||
|
_shutting_down = True
|
||||||
|
close_all_subscribers()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Application lifespan handler."""
|
"""Application lifespan handler."""
|
||||||
|
global _shutting_down
|
||||||
|
_shutting_down = False
|
||||||
|
|
||||||
await bus.start()
|
await bus.start()
|
||||||
poll_task = asyncio.create_task(health_poll_loop())
|
poll_task = asyncio.create_task(health_poll_loop())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
_shutting_down = True
|
||||||
poll_task.cancel()
|
poll_task.cancel()
|
||||||
|
close_all_subscribers()
|
||||||
await bus.stop()
|
await bus.stop()
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +72,7 @@ app.include_router(events_router)
|
|||||||
app.include_router(logs_router)
|
app.include_router(logs_router)
|
||||||
app.include_router(secrets_router)
|
app.include_router(secrets_router)
|
||||||
app.include_router(services_router)
|
app.include_router(services_router)
|
||||||
|
app.include_router(tools_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -70,6 +91,8 @@ async def sse_stream() -> StreamingResponse:
|
|||||||
yield "event: connected\ndata: {}\n\n"
|
yield "event: connected\ndata: {}\n\n"
|
||||||
while True:
|
while True:
|
||||||
msg = await q.get()
|
msg = await q.get()
|
||||||
|
if not msg:
|
||||||
|
break
|
||||||
yield msg
|
yield msg
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -85,12 +108,20 @@ async def sse_stream() -> StreamingResponse:
|
|||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
"""Run the application with uvicorn."""
|
"""Run the application with uvicorn."""
|
||||||
uvicorn.run(
|
config = uvicorn.Config(
|
||||||
"dashboard_api.main:app",
|
"dashboard_api.main:app",
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
reload=False,
|
reload=False,
|
||||||
)
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
async def serve_with_watcher() -> None:
|
||||||
|
watcher = asyncio.create_task(_watch_shutdown(server))
|
||||||
|
await server.serve()
|
||||||
|
watcher.cancel()
|
||||||
|
|
||||||
|
asyncio.run(serve_with_watcher())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class ComponentSummary(BaseModel):
|
|||||||
category: str | None = None
|
category: str | None = None
|
||||||
version: str | None = None
|
version: str | None = None
|
||||||
tool_type: str | None = None
|
tool_type: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
system_dependencies: list[str] = []
|
||||||
|
schedule: str | None = None
|
||||||
|
installed: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ComponentDetail(ComponentSummary):
|
class ComponentDetail(ComponentSummary):
|
||||||
@@ -54,3 +58,30 @@ class ServiceActionResponse(BaseModel):
|
|||||||
component: str
|
component: str
|
||||||
action: str
|
action: str
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSummary(BaseModel):
|
||||||
|
"""Summary of a single tool."""
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
installed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCategory(BaseModel):
|
||||||
|
"""Tools grouped by category."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
tools: list[ToolSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolDetail(ToolSummary):
|
||||||
|
"""Full detail for a single tool, including documentation."""
|
||||||
|
|
||||||
|
docs: str | None = None
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from castle_cli.config import load_config
|
from castle_cli.config import load_config
|
||||||
@@ -33,6 +35,19 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
|
|||||||
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
|
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract cron schedule from first schedule trigger, if any
|
||||||
|
schedule = None
|
||||||
|
for t in manifest.triggers:
|
||||||
|
if t.type == "schedule":
|
||||||
|
schedule = t.cron
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if tool is actually installed on PATH
|
||||||
|
installed: bool | None = None
|
||||||
|
if manifest.install and manifest.install.path:
|
||||||
|
alias = manifest.install.path.alias or name
|
||||||
|
installed = shutil.which(alias) is not None
|
||||||
|
|
||||||
return ComponentSummary(
|
return ComponentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
description=manifest.description,
|
description=manifest.description,
|
||||||
@@ -45,6 +60,10 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
|
|||||||
category=manifest.tool.category if manifest.tool else None,
|
category=manifest.tool.category if manifest.tool else None,
|
||||||
version=manifest.tool.version if manifest.tool else None,
|
version=manifest.tool.version if manifest.tool else None,
|
||||||
tool_type=manifest.tool.tool_type.value 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,
|
||||||
|
installed=installed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from castle_cli.config import load_config
|
from castle_cli.config import load_config
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ from dashboard_api.stream import broadcast
|
|||||||
router = APIRouter(prefix="/services", tags=["services"])
|
router = APIRouter(prefix="/services", tags=["services"])
|
||||||
|
|
||||||
UNIT_PREFIX = "castle-"
|
UNIT_PREFIX = "castle-"
|
||||||
|
SELF_NAME = "dashboard-api"
|
||||||
|
|
||||||
|
|
||||||
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
||||||
@@ -77,10 +79,25 @@ async def _broadcast_health_with_override(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
async def _do_action(name: str, action: str) -> dict:
|
async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None:
|
||||||
|
"""Run a systemctl action after a delay, allowing the HTTP response to flush."""
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await _systemctl(action, unit)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_action(name: str, action: str) -> JSONResponse:
|
||||||
"""Execute a systemctl action and broadcast updated health."""
|
"""Execute a systemctl action and broadcast updated health."""
|
||||||
_validate_managed(name)
|
_validate_managed(name)
|
||||||
unit = f"{UNIT_PREFIX}{name}.service"
|
unit = f"{UNIT_PREFIX}{name}.service"
|
||||||
|
|
||||||
|
# Self-restart: defer the systemctl call so the response can be sent first
|
||||||
|
if name == SELF_NAME and action in ("restart", "stop"):
|
||||||
|
asyncio.create_task(_deferred_systemctl(action, unit))
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=202,
|
||||||
|
content={"component": name, "action": action, "status": "accepted"},
|
||||||
|
)
|
||||||
|
|
||||||
ok, output = await _systemctl(action, unit)
|
ok, output = await _systemctl(action, unit)
|
||||||
unit_status = await _get_unit_status(unit)
|
unit_status = await _get_unit_status(unit)
|
||||||
|
|
||||||
@@ -90,22 +107,24 @@ async def _do_action(name: str, action: str) -> dict:
|
|||||||
# Broadcast immediately with systemd status as the source of truth
|
# Broadcast immediately with systemd status as the source of truth
|
||||||
await _broadcast_health_with_override(name, unit_status)
|
await _broadcast_health_with_override(name, unit_status)
|
||||||
|
|
||||||
return {"component": name, "action": action, "status": unit_status}
|
return JSONResponse(
|
||||||
|
content={"component": name, "action": action, "status": unit_status},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{name}/start")
|
@router.post("/{name}/start")
|
||||||
async def start_service(name: str) -> dict:
|
async def start_service(name: str) -> JSONResponse:
|
||||||
"""Start a systemd-managed service."""
|
"""Start a systemd-managed service."""
|
||||||
return await _do_action(name, "start")
|
return await _do_action(name, "start")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{name}/stop")
|
@router.post("/{name}/stop")
|
||||||
async def stop_service(name: str) -> dict:
|
async def stop_service(name: str) -> JSONResponse:
|
||||||
"""Stop a systemd-managed service."""
|
"""Stop a systemd-managed service."""
|
||||||
return await _do_action(name, "stop")
|
return await _do_action(name, "stop")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{name}/restart")
|
@router.post("/{name}/restart")
|
||||||
async def restart_service(name: str) -> dict:
|
async def restart_service(name: str) -> JSONResponse:
|
||||||
"""Restart a systemd-managed service."""
|
"""Restart a systemd-managed service."""
|
||||||
return await _do_action(name, "restart")
|
return await _do_action(name, "restart")
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ def unsubscribe(q: asyncio.Queue[str]) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def close_all_subscribers() -> None:
|
||||||
|
"""Unblock all SSE generators so they exit during shutdown."""
|
||||||
|
for q in list(_subscribers):
|
||||||
|
try:
|
||||||
|
q.put_nowait("")
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
_subscribers.clear()
|
||||||
|
|
||||||
|
|
||||||
async def broadcast(event_type: str, data: dict) -> None:
|
async def broadcast(event_type: str, data: dict) -> None:
|
||||||
"""Send an event to all connected SSE clients."""
|
"""Send an event to all connected SSE clients."""
|
||||||
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
||||||
|
|||||||
177
dashboard-api/src/dashboard_api/tools.py
Normal file
177
dashboard-api/src/dashboard_api/tools.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Tools router — browse and inspect tool components."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from castle_cli.config import load_config
|
||||||
|
from castle_cli.manifest import ComponentManifest
|
||||||
|
|
||||||
|
from dashboard_api.config import settings
|
||||||
|
from dashboard_api.models import ToolCategory, ToolDetail, ToolSummary
|
||||||
|
|
||||||
|
router = APIRouter(tags=["tools"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_summary(name: str, manifest: ComponentManifest) -> 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)
|
||||||
|
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,
|
||||||
|
system_dependencies=t.system_dependencies,
|
||||||
|
installed=installed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
if source_path.is_file():
|
||||||
|
md = source_path.with_suffix(".md")
|
||||||
|
if md.exists():
|
||||||
|
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
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_frontmatter(content: str) -> str:
|
||||||
|
"""Strip YAML frontmatter from markdown content."""
|
||||||
|
if content.startswith("---\n"):
|
||||||
|
end = content.find("\n---\n", 4)
|
||||||
|
if end != -1:
|
||||||
|
content = content[end + 5:]
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools", response_model=list[ToolCategory])
|
||||||
|
def list_tools() -> list[ToolCategory]:
|
||||||
|
"""List tools grouped by category."""
|
||||||
|
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]] = {}
|
||||||
|
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))
|
||||||
|
|
||||||
|
return [
|
||||||
|
ToolCategory(name=cat, tools=sorted(items, key=lambda t: t.id))
|
||||||
|
for cat, items in sorted(by_category.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools/{name}", response_model=ToolDetail)
|
||||||
|
def get_tool(name: str) -> ToolDetail:
|
||||||
|
"""Get detailed info for a single tool."""
|
||||||
|
config = load_config(settings.castle_root)
|
||||||
|
|
||||||
|
if name not in config.components:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Component '{name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = config.components[name]
|
||||||
|
if not manifest.tool:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"'{name}' is not a tool",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = _tool_summary(name, manifest)
|
||||||
|
docs: str | None = None
|
||||||
|
t = manifest.tool
|
||||||
|
if t.source:
|
||||||
|
md_path = _find_md_for_tool(config.root, t.source, name, t.category)
|
||||||
|
if md_path and md_path.exists():
|
||||||
|
docs = _strip_frontmatter(md_path.read_text())
|
||||||
|
if not docs:
|
||||||
|
docs = None
|
||||||
|
|
||||||
|
return ToolDetail(**summary.model_dump(), docs=docs)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tools/{name}/install")
|
||||||
|
async def install_tool(name: str) -> dict:
|
||||||
|
"""Install a tool to PATH via uv tool install."""
|
||||||
|
config = load_config(settings.castle_root)
|
||||||
|
if name not in config.components:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found")
|
||||||
|
|
||||||
|
manifest = config.components[name]
|
||||||
|
if not manifest.tool or not manifest.tool.source:
|
||||||
|
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source to install")
|
||||||
|
|
||||||
|
source_dir = config.root / manifest.tool.source
|
||||||
|
if not (source_dir / "pyproject.toml").exists():
|
||||||
|
raise HTTPException(status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}")
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"uv", "tool", "install", "--editable", str(source_dir), "--force",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
output = (stdout or stderr or b"").decode().strip()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise HTTPException(status_code=500, detail=output or "Install failed")
|
||||||
|
|
||||||
|
return {"component": name, "action": "install", "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tools/{name}/uninstall")
|
||||||
|
async def uninstall_tool(name: str) -> dict:
|
||||||
|
"""Uninstall a tool from PATH via uv tool uninstall."""
|
||||||
|
config = load_config(settings.castle_root)
|
||||||
|
if name not in config.components:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found")
|
||||||
|
|
||||||
|
manifest = config.components[name]
|
||||||
|
if not manifest.tool or not manifest.tool.source:
|
||||||
|
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source")
|
||||||
|
|
||||||
|
# uv tool uninstall uses the package name from pyproject.toml
|
||||||
|
source_dir = config.root / manifest.tool.source
|
||||||
|
# Try to read the package name; fall back to the source dir name
|
||||||
|
pkg_name = source_dir.name
|
||||||
|
pyproject = source_dir / "pyproject.toml"
|
||||||
|
if pyproject.exists():
|
||||||
|
import tomllib
|
||||||
|
with open(pyproject, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
pkg_name = data.get("project", {}).get("name", pkg_name)
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"uv", "tool", "uninstall", pkg_name,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
output = (stdout or stderr or b"").decode().strip()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise HTTPException(status_code=500, detail=output or "Uninstall failed")
|
||||||
|
|
||||||
|
return {"component": name, "action": "uninstall", "status": "ok"}
|
||||||
@@ -37,6 +37,19 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"test-tool": {
|
"test-tool": {
|
||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"install": {"path": {"alias": "test-tool"}},
|
"install": {"path": {"alias": "test-tool"}},
|
||||||
|
"tool": {
|
||||||
|
"category": "document",
|
||||||
|
"source": "tools/document",
|
||||||
|
"system_dependencies": ["pandoc"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test-tool-2": {
|
||||||
|
"description": "Another test tool",
|
||||||
|
"tool": {
|
||||||
|
"category": "utility",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tool_type": "script",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,6 @@ class TestGateway:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["port"] == 9000
|
assert data["port"] == 9000
|
||||||
assert data["component_count"] == 2
|
assert data["component_count"] == 3
|
||||||
assert data["service_count"] == 1
|
assert data["service_count"] == 1
|
||||||
assert data["managed_count"] == 1
|
assert data["managed_count"] == 1
|
||||||
|
|||||||
105
dashboard-api/tests/test_tools.py
Normal file
105
dashboard-api/tests/test_tools.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for tools endpoints."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolsList:
|
||||||
|
"""GET /tools endpoint tests."""
|
||||||
|
|
||||||
|
def test_returns_grouped_tools(self, client: TestClient) -> None:
|
||||||
|
"""Returns tools grouped by category."""
|
||||||
|
response = client.get("/tools")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [cat["name"] for cat in data]
|
||||||
|
assert "document" in names
|
||||||
|
assert "utility" in names
|
||||||
|
|
||||||
|
def test_categories_sorted(self, client: TestClient) -> None:
|
||||||
|
"""Categories are sorted alphabetically."""
|
||||||
|
response = client.get("/tools")
|
||||||
|
data = response.json()
|
||||||
|
names = [cat["name"] for cat in data]
|
||||||
|
assert names == sorted(names)
|
||||||
|
|
||||||
|
def test_tool_fields(self, client: TestClient) -> None:
|
||||||
|
"""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")
|
||||||
|
assert tool["description"] == "Test tool"
|
||||||
|
assert tool["category"] == "document"
|
||||||
|
assert tool["source"] == "tools/document"
|
||||||
|
assert tool["system_dependencies"] == ["pandoc"]
|
||||||
|
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
assert tool["installed"] is False
|
||||||
|
|
||||||
|
def test_service_excluded(self, client: TestClient) -> None:
|
||||||
|
"""Services without tool spec are not listed."""
|
||||||
|
response = client.get("/tools")
|
||||||
|
data = response.json()
|
||||||
|
all_ids = [t["id"] for cat in data for t in cat["tools"]]
|
||||||
|
assert "test-svc" not in all_ids
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolDetail:
|
||||||
|
"""GET /tools/{name} endpoint tests."""
|
||||||
|
|
||||||
|
def test_get_tool(self, client: TestClient) -> None:
|
||||||
|
"""Returns detail for a known tool."""
|
||||||
|
response = client.get("/tools/test-tool")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == "test-tool"
|
||||||
|
assert data["category"] == "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
|
||||||
|
doc_dir = castle_root / "tools" / "document" / "src" / "document"
|
||||||
|
doc_dir.mkdir(parents=True)
|
||||||
|
(doc_dir / "test_tool.md").write_text(
|
||||||
|
"---\ntitle: Test\n---\n\n# Test Tool\n\nUsage info here."
|
||||||
|
)
|
||||||
|
response = client.get("/tools/test-tool")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["docs"] is not None
|
||||||
|
assert "# Test Tool" in data["docs"]
|
||||||
|
# Frontmatter should be stripped
|
||||||
|
assert "---" not in data["docs"]
|
||||||
|
|
||||||
|
def test_no_docs(self, client: TestClient) -> None:
|
||||||
|
"""Tool with no doc file returns null docs."""
|
||||||
|
response = client.get("/tools/test-tool")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["docs"] is None
|
||||||
|
|
||||||
|
def test_not_found(self, client: TestClient) -> None:
|
||||||
|
"""Returns 404 for unknown component."""
|
||||||
|
response = client.get("/tools/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_not_a_tool(self, client: TestClient) -> None:
|
||||||
|
"""Returns 404 for component that is not a tool."""
|
||||||
|
response = client.get("/tools/test-svc")
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -2,6 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-
|
|||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||||
import { useServiceAction } from "@/services/api/hooks"
|
import { useServiceAction } from "@/services/api/hooks"
|
||||||
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { HealthBadge } from "./HealthBadge"
|
import { HealthBadge } from "./HealthBadge"
|
||||||
import { RoleBadge } from "./RoleBadge"
|
import { RoleBadge } from "./RoleBadge"
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
|
|||||||
{component.runner && (
|
{component.runner && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
{component.runner}
|
{runnerLabel(component.runner)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{component.proxy_path && (
|
{component.proxy_path && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Check, Loader2, Save, Trash2 } from "lucide-react"
|
import { Check, Loader2, Save, Trash2 } from "lucide-react"
|
||||||
import type { ComponentDetail } from "@/types"
|
import type { ComponentDetail } from "@/types"
|
||||||
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { SecretsEditor } from "./SecretsEditor"
|
import { SecretsEditor } from "./SecretsEditor"
|
||||||
|
|
||||||
interface ComponentFieldsProps {
|
interface ComponentFieldsProps {
|
||||||
@@ -113,7 +114,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
|||||||
{runner && (
|
{runner && (
|
||||||
<Field label="Runner">
|
<Field label="Runner">
|
||||||
<span className="text-sm font-mono text-[var(--muted)]">
|
<span className="text-sm font-mono text-[var(--muted)]">
|
||||||
{runner}
|
{runnerLabel(runner)}
|
||||||
{(m.run as Record<string, string>)?.tool && (
|
{(m.run as Record<string, string>)?.tool && (
|
||||||
<> · {(m.run as Record<string, string>).tool}</>
|
<> · {(m.run as Record<string, string>).tool}</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
333
dashboard/src/components/ComponentTable.tsx
Normal file
333
dashboard/src/components/ComponentTable.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
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 { HealthBadge } from "./HealthBadge"
|
||||||
|
import { RoleBadge } from "./RoleBadge"
|
||||||
|
|
||||||
|
interface ComponentTableProps {
|
||||||
|
components: ComponentSummary[]
|
||||||
|
statuses: HealthStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "id" | "role" | "runner" | "tool_type" | "category" | "schedule" | "port" | "status"
|
||||||
|
type SortDir = "asc" | "desc"
|
||||||
|
|
||||||
|
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
|
||||||
|
// Health takes priority for services
|
||||||
|
if (s) {
|
||||||
|
if (s.status === "down") return 0
|
||||||
|
if (s.status === "up") return 3
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
// Installed state for tools
|
||||||
|
if (installed === false) return 1
|
||||||
|
if (installed === true) return 3
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||||
|
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
|
||||||
|
const allRoles = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const c of components) for (const r of c.roles) set.add(r)
|
||||||
|
return Array.from(set).sort()
|
||||||
|
}, [components])
|
||||||
|
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string | null>(null)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("id")
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||||
|
|
||||||
|
const toggleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDir("asc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = components
|
||||||
|
if (roleFilter) {
|
||||||
|
list = list.filter((c) => c.roles.includes(roleFilter))
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
list = list.filter(
|
||||||
|
(c) =>
|
||||||
|
c.id.toLowerCase().includes(q) ||
|
||||||
|
(c.description?.toLowerCase().includes(q) ?? false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [components, roleFilter, search])
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const dir = sortDir === "asc" ? 1 : -1
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case "id":
|
||||||
|
return dir * a.id.localeCompare(b.id)
|
||||||
|
case "role":
|
||||||
|
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":
|
||||||
|
return dir * ((a.port ?? 0) - (b.port ?? 0))
|
||||||
|
case "status":
|
||||||
|
return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed))
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [filtered, sortKey, sortDir, statusMap])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Filter components..."
|
||||||
|
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleFilter(null)}
|
||||||
|
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||||
|
roleFilter === null
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{allRoles.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => setRoleFilter(roleFilter === role ? null : role)}
|
||||||
|
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||||
|
roleFilter === role
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||||
|
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Roles" sortKey="role" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Package" sortKey="tool_type" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Schedule" sortKey="schedule" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Port" sortKey="port" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
|
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((comp) => (
|
||||||
|
<ComponentRow
|
||||||
|
key={comp.id}
|
||||||
|
component={comp}
|
||||||
|
health={statusMap.get(comp.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-3 py-6 text-center text-[var(--muted)]">
|
||||||
|
No components match.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortHeader({
|
||||||
|
label,
|
||||||
|
sortKey,
|
||||||
|
current,
|
||||||
|
dir,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
sortKey: SortKey
|
||||||
|
current: SortKey
|
||||||
|
dir: SortDir
|
||||||
|
onSort: (key: SortKey) => void
|
||||||
|
}) {
|
||||||
|
const active = current === sortKey
|
||||||
|
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown
|
||||||
|
return (
|
||||||
|
<th className="px-3 py-2 font-medium text-[var(--muted)]">
|
||||||
|
<button
|
||||||
|
onClick={() => onSort(sortKey)}
|
||||||
|
className="flex items-center gap-1 hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<Icon size={12} className={active ? "text-[var(--foreground)]" : ""} />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstalledBadge({ installed }: { installed: boolean }) {
|
||||||
|
return installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800/50">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||||
|
installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-zinc-800/40 text-[var(--muted)] border border-[var(--border)]">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-zinc-500" />
|
||||||
|
not installed
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentRow({
|
||||||
|
component,
|
||||||
|
health,
|
||||||
|
}: {
|
||||||
|
component: ComponentSummary
|
||||||
|
health?: HealthStatus
|
||||||
|
}) {
|
||||||
|
const hasHttp = component.port != null
|
||||||
|
const isTool = component.installed !== null
|
||||||
|
const { mutate: serviceAction, isPending: servicePending } = useServiceAction()
|
||||||
|
const { mutate: toolAction, isPending: toolPending } = useToolAction()
|
||||||
|
const isDown = health?.status === "down"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Link
|
||||||
|
to={`/${component.id}`}
|
||||||
|
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{component.id}
|
||||||
|
</Link>
|
||||||
|
{component.description && (
|
||||||
|
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
|
||||||
|
{component.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{component.roles.map((role) => (
|
||||||
|
<RoleBadge key={role} role={role} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||||
|
{component.runner ? runnerLabel(component.runner) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||||
|
{component.tool_type ? toolTypeLabel(component.tool_type) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||||
|
{component.category ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||||
|
{component.schedule ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||||
|
{component.port ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{health ? (
|
||||||
|
<HealthBadge status={health.status} latency={health.latency_ms} />
|
||||||
|
) : hasHttp ? (
|
||||||
|
<HealthBadge status="unknown" />
|
||||||
|
) : isTool ? (
|
||||||
|
<InstalledBadge installed={component.installed!} />
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{component.managed ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isDown && (
|
||||||
|
<button
|
||||||
|
onClick={() => serviceAction({ name: component.id, action: "start" })}
|
||||||
|
disabled={servicePending}
|
||||||
|
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Start"
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => serviceAction({ name: component.id, action: "restart" })}
|
||||||
|
disabled={servicePending}
|
||||||
|
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Restart"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{!isDown && (
|
||||||
|
<button
|
||||||
|
onClick={() => serviceAction({ name: component.id, action: "stop" })}
|
||||||
|
disabled={servicePending}
|
||||||
|
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<Square size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isTool ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{component.installed ? (
|
||||||
|
<button
|
||||||
|
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
|
||||||
|
disabled={toolPending}
|
||||||
|
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Uninstall from PATH"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => toolAction({ name: component.id, action: "install" })}
|
||||||
|
disabled={toolPending}
|
||||||
|
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Install to PATH"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ROLE_DESCRIPTIONS } from "@/lib/labels"
|
||||||
|
|
||||||
const roleColors: Record<string, string> = {
|
const roleColors: Record<string, string> = {
|
||||||
service: "bg-green-700 text-white",
|
service: "bg-green-700 text-white",
|
||||||
@@ -17,6 +18,7 @@ export function RoleBadge({ role }: { role: string }) {
|
|||||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||||
roleColors[role] ?? "bg-gray-600 text-gray-200",
|
roleColors[role] ?? "bg-gray-600 text-gray-200",
|
||||||
)}
|
)}
|
||||||
|
title={ROLE_DESCRIPTIONS[role]}
|
||||||
>
|
>
|
||||||
{role}
|
{role}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
57
dashboard/src/components/ToolCard.tsx
Normal file
57
dashboard/src/components/ToolCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { Terminal } from "lucide-react"
|
||||||
|
import type { ToolSummary } from "@/types"
|
||||||
|
import { runnerLabel } from "@/lib/labels"
|
||||||
|
|
||||||
|
interface ToolCardProps {
|
||||||
|
tool: ToolSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCard({ tool }: ToolCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<Link
|
||||||
|
to={`/${tool.id}`}
|
||||||
|
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{tool.id}
|
||||||
|
</Link>
|
||||||
|
{tool.installed && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-green-900/30 text-green-400 border border-green-800">
|
||||||
|
installed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tool.description && (
|
||||||
|
<p className="text-sm text-[var(--muted)] mb-3">{tool.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--muted)] mb-3">
|
||||||
|
{tool.runner && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Terminal size={12} />
|
||||||
|
{runnerLabel(tool.runner)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tool.version && (
|
||||||
|
<span className="font-mono">v{tool.version}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tool.system_dependencies.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tool.system_dependencies.map((dep) => (
|
||||||
|
<span
|
||||||
|
key={dep}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
|
||||||
|
>
|
||||||
|
{dep}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
dashboard/src/lib/labels.ts
Normal file
31
dashboard/src/lib/labels.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export const RUNNER_LABELS: Record<string, string> = {
|
||||||
|
python_uv_tool: "Python (uv)",
|
||||||
|
command: "Command",
|
||||||
|
python_module: "Python module",
|
||||||
|
container: "Container",
|
||||||
|
node: "Node.js",
|
||||||
|
remote: "Remote",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOOL_TYPE_LABELS: Record<string, string> = {
|
||||||
|
python_standalone: "Python package",
|
||||||
|
script: "Shell script",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
service: "Exposes HTTP endpoints",
|
||||||
|
tool: "CLI utility installed to PATH",
|
||||||
|
worker: "Background process (no HTTP)",
|
||||||
|
job: "Runs on a schedule",
|
||||||
|
frontend: "Built static assets",
|
||||||
|
remote: "Hosted externally",
|
||||||
|
containerized: "Runs in a container",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runnerLabel(runner: string): string {
|
||||||
|
return RUNNER_LABELS[runner] ?? runner
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolTypeLabel(toolType: string): string {
|
||||||
|
return TOOL_TYPE_LABELS[toolType] ?? toolType
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { useParams, Link, useNavigate } from "react-router-dom"
|
|||||||
import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react"
|
import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
import { useComponent, useStatus, useServiceAction, useEventStream } from "@/services/api/hooks"
|
import { useComponent, useStatus, useServiceAction, useEventStream, useToolDetail } from "@/services/api/hooks"
|
||||||
|
import { runnerLabel, toolTypeLabel } from "@/lib/labels"
|
||||||
import { ComponentFields } from "@/components/ComponentFields"
|
import { ComponentFields } from "@/components/ComponentFields"
|
||||||
import { HealthBadge } from "@/components/HealthBadge"
|
import { HealthBadge } from "@/components/HealthBadge"
|
||||||
import { LogViewer } from "@/components/LogViewer"
|
import { LogViewer } from "@/components/LogViewer"
|
||||||
@@ -19,6 +20,8 @@ export function ComponentDetailPage() {
|
|||||||
const { mutate, isPending } = useServiceAction()
|
const { mutate, isPending } = useServiceAction()
|
||||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||||
const isDown = health?.status === "down"
|
const isDown = health?.status === "down"
|
||||||
|
const isTool = component?.roles.includes("tool") ?? false
|
||||||
|
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||||
|
|
||||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||||
@@ -130,6 +133,72 @@ export function ComponentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{toolDetail && (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
|
||||||
|
Tool Info
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--muted)] mb-4">
|
||||||
|
How this tool is packaged and what it depends on.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
|
||||||
|
{toolDetail.category && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--muted)]">Category</span>
|
||||||
|
<span>{toolDetail.category}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{toolDetail.source && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--muted)]">Source</span>
|
||||||
|
<span className="font-mono">{toolDetail.source}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{toolDetail.version && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--muted)]">Version</span>
|
||||||
|
<span>{toolDetail.version}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{toolDetail.tool_type && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--muted)]">Type</span>
|
||||||
|
<span>{toolTypeLabel(toolDetail.tool_type)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{toolDetail.runner && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--muted)]">Runner</span>
|
||||||
|
<span>{runnerLabel(toolDetail.runner)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{toolDetail.system_dependencies.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{toolDetail.system_dependencies.map((dep) => (
|
||||||
|
<span
|
||||||
|
key={dep}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
|
||||||
|
>
|
||||||
|
{dep}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolDetail.docs && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-[var(--muted)] block mb-1">Documentation</span>
|
||||||
|
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)]">
|
||||||
|
{toolDetail.docs}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
|
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
|
||||||
Configuration
|
Configuration
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Link } from "react-router-dom"
|
import { ComponentTable } from "@/components/ComponentTable"
|
||||||
import { Settings } from "lucide-react"
|
|
||||||
import { ComponentGrid } from "@/components/ComponentGrid"
|
|
||||||
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
|
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -11,8 +9,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div className="flex items-start justify-between mb-8">
|
<div className="mb-8">
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Castle</h1>
|
<h1 className="text-3xl font-bold">Castle</h1>
|
||||||
<p className="text-[var(--muted)] mt-1">
|
<p className="text-[var(--muted)] mt-1">
|
||||||
Personal software platform
|
Personal software platform
|
||||||
@@ -23,18 +20,11 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
to="/config"
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-[var(--border)] hover:bg-gray-600 text-[var(--foreground)] transition-colors"
|
|
||||||
>
|
|
||||||
<Settings size={14} /> Config
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-[var(--muted)]">Loading components...</p>
|
<p className="text-[var(--muted)]">Loading components...</p>
|
||||||
) : components ? (
|
) : components ? (
|
||||||
<ComponentGrid
|
<ComponentTable
|
||||||
components={components}
|
components={components}
|
||||||
statuses={statusResp?.statuses ?? []}
|
statuses={statusResp?.statuses ?? []}
|
||||||
/>
|
/>
|
||||||
|
|||||||
42
dashboard/src/pages/Tools.tsx
Normal file
42
dashboard/src/pages/Tools.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { useTools } from "@/services/api/hooks"
|
||||||
|
import { ToolCard } from "@/components/ToolCard"
|
||||||
|
|
||||||
|
export function ToolsPage() {
|
||||||
|
const { data: categories, isLoading } = useTools()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
|
||||||
|
<ArrowLeft size={16} /> Back
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Tools</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)] mb-8">
|
||||||
|
CLI utilities grouped by category. Each tool is installed to PATH via castle and run with uv.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-[var(--muted)]">Loading tools...</p>
|
||||||
|
) : categories?.length ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<section key={cat.name}>
|
||||||
|
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
|
||||||
|
{cat.name}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{cat.tools.map((tool) => (
|
||||||
|
<ToolCard key={tool.id} tool={tool} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[var(--muted)]">No tools registered.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom"
|
import { createBrowserRouter } from "react-router-dom"
|
||||||
import { Dashboard } from "@/pages/Dashboard"
|
import { Dashboard } from "@/pages/Dashboard"
|
||||||
import { ComponentDetailPage } from "@/pages/ComponentDetail"
|
import { ComponentDetailPage } from "@/pages/ComponentDetail"
|
||||||
import { ConfigEditorPage } from "@/pages/ConfigEditor"
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Dashboard />,
|
element: <Dashboard />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/config",
|
|
||||||
element: <ConfigEditorPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/:name",
|
path: "/:name",
|
||||||
element: <ComponentDetailPage />,
|
element: <ComponentDetailPage />,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
GatewayInfo,
|
GatewayInfo,
|
||||||
ServiceActionResponse,
|
ServiceActionResponse,
|
||||||
SSEHealthEvent,
|
SSEHealthEvent,
|
||||||
|
ToolCategory,
|
||||||
|
ToolDetail,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
|
|
||||||
export function useComponents() {
|
export function useComponents() {
|
||||||
@@ -41,11 +43,66 @@ export function useGateway() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForApi(attempts = 20, interval = 1000): Promise<void> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
await apiClient.get<{ status: string }>("/health")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
await new Promise((r) => setTimeout(r, interval))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useServiceAction() {
|
export function useServiceAction() {
|
||||||
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ name, action }: { name: string; action: string }) =>
|
mutationFn: async ({ name, action }: { name: string; action: string }) => {
|
||||||
apiClient.post<ServiceActionResponse>(`/services/${name}/${action}`),
|
try {
|
||||||
// SSE health event handles the UI update; no need to refetch here
|
return await apiClient.post<ServiceActionResponse>(`/services/${name}/${action}`)
|
||||||
|
} catch (err) {
|
||||||
|
// Network error from self-restart killing the connection — expected
|
||||||
|
if (err instanceof TypeError) {
|
||||||
|
return { component: name, action, status: "accepted" } as ServiceActionResponse
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
if (data.status === "accepted") {
|
||||||
|
// API is restarting itself — poll until it's back, then refresh everything
|
||||||
|
await waitForApi()
|
||||||
|
qc.invalidateQueries()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToolAction() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ name, action }: { name: string; action: "install" | "uninstall" }) =>
|
||||||
|
apiClient.post<{ component: string; action: string; status: string }>(
|
||||||
|
`/tools/${name}/${action}`,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["components"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTools() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["tools"],
|
||||||
|
queryFn: () => apiClient.get<ToolCategory[]>("/tools"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToolDetail(name: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["tools", name],
|
||||||
|
queryFn: () => apiClient.get<ToolDetail>(`/tools/${name}`),
|
||||||
|
enabled: !!name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export interface ComponentSummary {
|
|||||||
health_path: string | null
|
health_path: string | null
|
||||||
proxy_path: string | null
|
proxy_path: string | null
|
||||||
managed: boolean
|
managed: boolean
|
||||||
|
category: string | null
|
||||||
|
version: string | null
|
||||||
|
tool_type: string | null
|
||||||
|
source: string | null
|
||||||
|
system_dependencies: string[]
|
||||||
|
schedule: string | null
|
||||||
|
installed: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentDetail extends ComponentSummary {
|
export interface ComponentDetail extends ComponentSummary {
|
||||||
@@ -47,11 +54,23 @@ export interface SSEServiceActionEvent {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolInfo {
|
export interface ToolSummary {
|
||||||
command: string
|
id: string
|
||||||
description: string
|
description: string | null
|
||||||
category: string
|
category: string | null
|
||||||
version: string
|
source: string | null
|
||||||
|
tool_type: string | null
|
||||||
|
version: string | null
|
||||||
|
runner: string | null
|
||||||
system_dependencies: string[]
|
system_dependencies: string[]
|
||||||
script: string
|
installed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCategory {
|
||||||
|
name: string
|
||||||
|
tools: ToolSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDetail extends ToolSummary {
|
||||||
|
docs: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
310
docs/component-registry.md
Normal file
310
docs/component-registry.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Component Registry
|
||||||
|
|
||||||
|
How castle tracks, configures, and manages components. This is the central
|
||||||
|
reference for `castle.yaml` structure and the manifest architecture.
|
||||||
|
|
||||||
|
## castle.yaml
|
||||||
|
|
||||||
|
The single source of truth for all components. Lives at the repo root.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gateway:
|
||||||
|
port: 9000
|
||||||
|
|
||||||
|
components:
|
||||||
|
my-service:
|
||||||
|
description: Does something useful
|
||||||
|
run:
|
||||||
|
runner: python_uv_tool
|
||||||
|
tool: my-service
|
||||||
|
cwd: my-service
|
||||||
|
env:
|
||||||
|
MY_SERVICE_DATA_DIR: /data/castle/my-service
|
||||||
|
MY_SERVICE_PORT: "9001"
|
||||||
|
expose:
|
||||||
|
http:
|
||||||
|
internal: { port: 9001 }
|
||||||
|
health_path: /health
|
||||||
|
proxy:
|
||||||
|
caddy: { path_prefix: /my-service }
|
||||||
|
manage:
|
||||||
|
systemd: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest blocks
|
||||||
|
|
||||||
|
Each component declares **what it does** through these optional blocks:
|
||||||
|
|
||||||
|
### `run` — How to start it
|
||||||
|
|
||||||
|
Discriminated union on `runner`:
|
||||||
|
|
||||||
|
| Runner | Use case | Key fields |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| `python_uv_tool` | Python service/tool via uv | `tool`, `cwd`, `env` |
|
||||||
|
| `command` | Shell command | `argv`, `cwd`, `env` |
|
||||||
|
| `python_module` | Python -m invocation | `module`, `args`, `python` |
|
||||||
|
| `container` | Docker/Podman | `image`, `command`, `ports`, `volumes` |
|
||||||
|
| `node` | Node.js script | `script`, `package_manager` (npm/pnpm/yarn) |
|
||||||
|
| `remote` | External service | `base_url`, `health_url` |
|
||||||
|
|
||||||
|
**Services** use `python_uv_tool`:
|
||||||
|
```yaml
|
||||||
|
run:
|
||||||
|
runner: python_uv_tool
|
||||||
|
tool: my-service # name in [project.scripts]
|
||||||
|
cwd: my-service # working directory relative to repo root
|
||||||
|
env:
|
||||||
|
MY_SERVICE_DATA_DIR: /data/castle/my-service
|
||||||
|
MY_SERVICE_PORT: "9001"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tools invoked by castle** (jobs, scheduled tasks) use `command`:
|
||||||
|
```yaml
|
||||||
|
run:
|
||||||
|
runner: command
|
||||||
|
argv: ["protonmail", "sync"]
|
||||||
|
cwd: protonmail
|
||||||
|
env:
|
||||||
|
PROTONMAIL_USERNAME: user@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Standalone tools** that users invoke directly often have no `run` block at
|
||||||
|
all — castle just installs them to PATH.
|
||||||
|
|
||||||
|
### `expose` — What it exposes
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
expose:
|
||||||
|
http:
|
||||||
|
internal:
|
||||||
|
port: 9001 # Required for services
|
||||||
|
health_path: /health # Used by health polling
|
||||||
|
```
|
||||||
|
|
||||||
|
Having `expose.http` gives the component the **service** role.
|
||||||
|
|
||||||
|
### `proxy` — How to proxy it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
proxy:
|
||||||
|
caddy:
|
||||||
|
path_prefix: /my-service # Proxied at gateway:9000/my-service/
|
||||||
|
```
|
||||||
|
|
||||||
|
Castle generates a Caddyfile from these entries. Only needed for services
|
||||||
|
accessible through the gateway.
|
||||||
|
|
||||||
|
### `manage` — How to manage it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
manage:
|
||||||
|
systemd: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables `castle service enable/disable` and `castle logs`. An empty `{}`
|
||||||
|
uses defaults (enable=true, restart=on-failure, restart_sec=5).
|
||||||
|
|
||||||
|
Full options:
|
||||||
|
```yaml
|
||||||
|
manage:
|
||||||
|
systemd:
|
||||||
|
description: Custom unit description
|
||||||
|
restart: always # on-failure | always | no
|
||||||
|
restart_sec: 2
|
||||||
|
no_new_privileges: true
|
||||||
|
after: [network.target, castle-other.service]
|
||||||
|
wanted_by: [default.target]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `install` — How to install it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
install:
|
||||||
|
path:
|
||||||
|
alias: my-tool # Command name in PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a shim so the tool is available system-wide after
|
||||||
|
`uv tool install --editable .`.
|
||||||
|
|
||||||
|
### `tool` — Tool metadata
|
||||||
|
|
||||||
|
```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]
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### `build` — How to build it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
build:
|
||||||
|
commands:
|
||||||
|
- ["pnpm", "build"]
|
||||||
|
outputs:
|
||||||
|
- dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Having build outputs gives the component the **frontend** role.
|
||||||
|
|
||||||
|
### `triggers` — What triggers it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
triggers:
|
||||||
|
- type: schedule
|
||||||
|
cron: "*/5 * * * *"
|
||||||
|
timezone: America/Los_Angeles # default
|
||||||
|
```
|
||||||
|
|
||||||
|
Having a schedule trigger gives the component the **job** role.
|
||||||
|
Castle generates a systemd .timer file alongside the .service unit.
|
||||||
|
|
||||||
|
Other trigger types: `manual`, `event` (source + topic), `request` (protocol).
|
||||||
|
|
||||||
|
### `env` with secrets
|
||||||
|
|
||||||
|
Environment variables can reference secrets stored in `~/.castle/secrets/`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
run:
|
||||||
|
env:
|
||||||
|
API_KEY: ${secret:MY_API_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`.
|
||||||
|
Never store secrets in castle.yaml or project directories.
|
||||||
|
|
||||||
|
## Role derivation
|
||||||
|
|
||||||
|
Roles are **computed** from manifest declarations, never set manually:
|
||||||
|
|
||||||
|
| Role | Derived when |
|
||||||
|
|------|-------------|
|
||||||
|
| **service** | Has `expose.http` |
|
||||||
|
| **tool** | Has `install.path` or has `tool` spec (fallback) |
|
||||||
|
| **worker** | Has `manage.systemd` but no `expose.http` |
|
||||||
|
| **job** | Has trigger with `type: schedule` |
|
||||||
|
| **frontend** | Has `build` with outputs or commands |
|
||||||
|
| **containerized** | Runner is `container` |
|
||||||
|
| **remote** | Runner is `remote` |
|
||||||
|
|
||||||
|
A component can have multiple roles. For example, `protonmail` is both a
|
||||||
|
**tool** (installed to PATH) and a **job** (runs on a cron schedule).
|
||||||
|
|
||||||
|
## Registering a new component
|
||||||
|
|
||||||
|
### Via `castle create` (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Service — scaffolds project, assigns port, registers in castle.yaml
|
||||||
|
castle create my-service --type service --description "Does something"
|
||||||
|
|
||||||
|
# Standalone tool — scaffolds at repo root
|
||||||
|
castle create my-tool --type tool --description "Does something"
|
||||||
|
|
||||||
|
# Category tool — adds to existing tools/<category>/ package
|
||||||
|
castle create my-tool --type tool --category document --description "Does something"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manually
|
||||||
|
|
||||||
|
Add an entry to the `components:` section of `castle.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
components:
|
||||||
|
my-tool:
|
||||||
|
description: Does something useful
|
||||||
|
tool:
|
||||||
|
tool_type: python_standalone
|
||||||
|
category: utilities
|
||||||
|
source: my-tool/
|
||||||
|
install:
|
||||||
|
path:
|
||||||
|
alias: my-tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
### Service lifecycle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle create my-service --type service # 1. Scaffold + register
|
||||||
|
cd my-service && uv sync # 2. Install deps
|
||||||
|
# ... implement ...
|
||||||
|
castle test my-service # 3. Run tests
|
||||||
|
castle service enable my-service # 4. Generate systemd unit, start
|
||||||
|
castle gateway reload # 5. Update Caddy routes
|
||||||
|
```
|
||||||
|
|
||||||
|
After `service enable`, the service starts automatically on boot and restarts
|
||||||
|
on failure. Manage with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle logs my-service -f # Tail logs
|
||||||
|
castle run my-service # Run in foreground (for debugging)
|
||||||
|
castle service disable my-service # Stop and remove systemd unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool lifecycle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle create my-tool --type tool # 1. Scaffold + register
|
||||||
|
cd my-tool && uv sync # 2. Install deps
|
||||||
|
# ... implement ...
|
||||||
|
castle test my-tool # 3. Run tests
|
||||||
|
uv tool install --editable my-tool/ # 4. Install to PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job lifecycle
|
||||||
|
|
||||||
|
Jobs are tools or services with a schedule trigger. They need both `run`
|
||||||
|
(so castle knows how to execute them) and `manage.systemd` (so systemd
|
||||||
|
handles the timer):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
my-job:
|
||||||
|
description: Runs nightly
|
||||||
|
run:
|
||||||
|
runner: command
|
||||||
|
argv: ["my-job"]
|
||||||
|
triggers:
|
||||||
|
- type: schedule
|
||||||
|
cron: "0 2 * * *"
|
||||||
|
manage:
|
||||||
|
systemd: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
`castle service enable my-job` generates both a `.service` (Type=oneshot)
|
||||||
|
and a `.timer` file.
|
||||||
|
|
||||||
|
## Infrastructure paths
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|------|-------|
|
||||||
|
| Component registry | `castle.yaml` (repo root) |
|
||||||
|
| Service data | `/data/castle/<name>/` |
|
||||||
|
| Secrets | `~/.castle/secrets/<NAME>` |
|
||||||
|
| Generated Caddyfile | `~/.castle/generated/Caddyfile` |
|
||||||
|
| Systemd units | `~/.config/systemd/user/castle-*.service` |
|
||||||
|
| Systemd timers | `~/.config/systemd/user/castle-*.timer` |
|
||||||
|
|
||||||
|
## Manifest models
|
||||||
|
|
||||||
|
The Pydantic models live in `cli/src/castle_cli/manifest.py`. Key classes:
|
||||||
|
|
||||||
|
- `ComponentManifest` — top-level model, has `roles` computed property
|
||||||
|
- `RunSpec` — discriminated union (RunPythonUvTool, RunCommand, etc.)
|
||||||
|
- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest)
|
||||||
|
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
|
||||||
|
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
||||||
|
|
||||||
|
Config loading: `cli/src/castle_cli/config.py` — `load_config()` parses
|
||||||
|
castle.yaml into `CastleConfig` with typed `components` dict.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# Python Tools in Castle
|
# Python Tools in Castle
|
||||||
|
|
||||||
How to build CLI tools following Unix philosophy. Based on the patterns in the
|
How to build CLI tools following Unix philosophy.
|
||||||
[toolkit](https://github.com/payneio/toolkit) project.
|
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
@@ -18,88 +17,120 @@ How to build CLI tools following Unix philosophy. Based on the patterns in the
|
|||||||
| **CLI** | argparse |
|
| **CLI** | argparse |
|
||||||
| **Package manager** | uv (never pip) |
|
| **Package manager** | uv (never pip) |
|
||||||
| **Build** | hatchling |
|
| **Build** | hatchling |
|
||||||
| **Testing** | unittest + mocking |
|
| **Testing** | pytest |
|
||||||
| **Linting** | ruff |
|
| **Linting** | ruff (shared `ruff.toml` at repo root) |
|
||||||
| **Type checking** | pyright |
|
| **Type checking** | pyright (shared `pyrightconfig.json` at repo root) |
|
||||||
|
| **Python** | 3.11+ minimum |
|
||||||
|
|
||||||
## Project layout
|
## Two kinds of tools
|
||||||
|
|
||||||
Tools live inside a single package with categories:
|
### Standalone tools
|
||||||
|
|
||||||
|
Independent projects at the repo root with their own `pyproject.toml`:
|
||||||
|
|
||||||
```
|
```
|
||||||
toolkit/
|
my-tool/
|
||||||
├── tools/
|
├── src/my_tool/
|
||||||
│ ├── document/
|
│ ├── __init__.py
|
||||||
│ │ ├── pdf2md.py # Implementation
|
│ └── main.py # Entry point
|
||||||
│ │ ├── pdf2md.md # Docs + YAML frontmatter (single source of truth)
|
├── tests/
|
||||||
│ │ └── test_pdf2md.py # Tests alongside tool
|
│ └── test_main.py
|
||||||
│ ├── search/
|
|
||||||
│ │ ├── search.py
|
|
||||||
│ │ ├── search.md
|
|
||||||
│ │ └── test_search.py
|
|
||||||
│ ├── system/
|
|
||||||
│ │ ├── schedule.py
|
|
||||||
│ │ └── schedule.md
|
|
||||||
│ └── toolkit/
|
|
||||||
│ ├── toolkit.py # Meta-tool for discovery/scaffolding
|
|
||||||
│ └── toolkit.md
|
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── Makefile
|
└── CLAUDE.md
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each tool is a `.py` + `.md` pair. The `.md` file has YAML frontmatter for
|
Examples: `devbox-connect/`, `mboxer/`, `protonmail/`
|
||||||
metadata — no separate config files needed.
|
|
||||||
|
|
||||||
## YAML frontmatter (.md file)
|
### Category tools
|
||||||
|
|
||||||
```yaml
|
Multiple tools sharing a single package under `tools/<category>/`:
|
||||||
---
|
|
||||||
command: pdf2md
|
|
||||||
script: document/pdf2md.py
|
|
||||||
description: Convert PDF files to Markdown
|
|
||||||
version: 1.0.0
|
|
||||||
category: document
|
|
||||||
system_dependencies:
|
|
||||||
- pandoc
|
|
||||||
- poppler-utils
|
|
||||||
---
|
|
||||||
|
|
||||||
# pdf2md
|
```
|
||||||
|
tools/document/
|
||||||
Converts PDF files to Markdown format...
|
├── src/document/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── pdf2md.py # Each tool is a module
|
||||||
|
│ ├── docx2md.py
|
||||||
|
│ ├── html2text.py
|
||||||
|
│ └── md2pdf.py
|
||||||
|
├── tests/
|
||||||
|
├── pyproject.toml # One pyproject with multiple [project.scripts]
|
||||||
|
└── CLAUDE.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The toolkit management command discovers tools by scanning for these `.md` files.
|
Examples: `tools/document/`, `tools/search/`, `tools/system/`
|
||||||
|
|
||||||
|
## Creating a new tool
|
||||||
|
|
||||||
|
### Standalone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle create my-tool --type tool --description "Does something"
|
||||||
|
cd my-tool && uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
This scaffolds the project and registers it in `castle.yaml`.
|
||||||
|
|
||||||
|
### Category tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle create my-tool --type tool --category document --description "Does something"
|
||||||
|
cd tools/document && uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds a `.py` file to the existing category package and updates its
|
||||||
|
`pyproject.toml` entry points.
|
||||||
|
|
||||||
## pyproject.toml
|
## pyproject.toml
|
||||||
|
|
||||||
|
### Standalone tool
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
name = "toolkit"
|
name = "my-tool"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
description = "Does something useful"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = []
|
||||||
"requests>=2.28.0",
|
|
||||||
"pyyaml>=6.0.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
pdf2md = "tools.document.pdf2md:main"
|
my-tool = "my_tool.main:main"
|
||||||
docx2md = "tools.document.docx2md:main"
|
|
||||||
search = "tools.search.search:main"
|
|
||||||
toolkit = "tools.toolkit.toolkit:main"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["tools"]
|
packages = ["src/my_tool"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=7.0.0"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["my_tool"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Entry points follow `command = "tools.<category>.<tool>:main"`. After
|
### Category package
|
||||||
`uv tool install --editable .`, all commands are in PATH.
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "castle-document"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Castle document conversion tools"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
docx2md = "document.docx2md:main"
|
||||||
|
pdf2md = "document.pdf2md:main"
|
||||||
|
html2text = "document.html2text:main"
|
||||||
|
md2pdf = "document.md2pdf:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/document"]
|
||||||
|
```
|
||||||
|
|
||||||
|
After `uv tool install --editable .`, all commands are in PATH.
|
||||||
|
|
||||||
## Tool implementation patterns
|
## Tool implementation patterns
|
||||||
|
|
||||||
@@ -113,8 +144,6 @@ to stdout.
|
|||||||
"""
|
"""
|
||||||
my-tool: Brief description
|
my-tool: Brief description
|
||||||
|
|
||||||
Detailed usage docs here.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
my-tool [options] [FILE]
|
my-tool [options] [FILE]
|
||||||
cat input.txt | my-tool
|
cat input.txt | my-tool
|
||||||
@@ -224,7 +253,7 @@ import sys
|
|||||||
|
|
||||||
def convert(input_file: str, output_file: str) -> int:
|
def convert(input_file: str, output_file: str) -> int:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
subprocess.run(
|
||||||
["pandoc", input_file, "-o", output_file],
|
["pandoc", input_file, "-o", output_file],
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -245,24 +274,6 @@ def convert(input_file: str, output_file: str) -> int:
|
|||||||
Always use `check=True` and `capture_output=True` with subprocess. Handle
|
Always use `check=True` and `capture_output=True` with subprocess. Handle
|
||||||
`FileNotFoundError` for missing system dependencies.
|
`FileNotFoundError` for missing system dependencies.
|
||||||
|
|
||||||
### Tool with optional dependencies
|
|
||||||
|
|
||||||
```python
|
|
||||||
tantivy_available = False
|
|
||||||
try:
|
|
||||||
import tantivy
|
|
||||||
tantivy_available = True
|
|
||||||
except ImportError:
|
|
||||||
tantivy = None
|
|
||||||
|
|
||||||
|
|
||||||
def check_tantivy() -> None:
|
|
||||||
if not tantivy_available:
|
|
||||||
print("Error: tantivy not found. Install with: uv add tantivy",
|
|
||||||
file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -301,119 +312,94 @@ for f in *.pdf; do pdf2md "$f" > "${f%.pdf}.md"; done
|
|||||||
cat doc.txt | text-extractor | jq .content
|
cat doc.txt | text-extractor | jq .content
|
||||||
```
|
```
|
||||||
|
|
||||||
When a tool writes to both a file and stdout, status messages must go to
|
|
||||||
stderr so they don't contaminate the pipe:
|
|
||||||
|
|
||||||
```python
|
|
||||||
with open(output_file, "w") as f:
|
|
||||||
f.write(result)
|
|
||||||
print(result) # stdout (for piping)
|
|
||||||
print(f"Wrote to {output_file}", file=sys.stderr) # stderr (status)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests live alongside the tool implementation, using unittest with mocking:
|
Tests use pytest. For standalone tools, test via subprocess to exercise the
|
||||||
|
real CLI interface:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# tools/gpt/test_gpt.py
|
import subprocess
|
||||||
import unittest
|
import sys
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from tools.gpt import gpt
|
|
||||||
|
|
||||||
|
|
||||||
class TestGPT(unittest.TestCase):
|
class TestCLI:
|
||||||
@patch("tools.gpt.gpt.get_api_key")
|
def test_version(self) -> None:
|
||||||
@patch("openai.OpenAI")
|
result = subprocess.run(
|
||||||
def test_generate(self, mock_openai, mock_key):
|
[sys.executable, "-m", "my_tool.main", "--version"],
|
||||||
mock_key.return_value = "fake-key"
|
capture_output=True,
|
||||||
mock_client = MagicMock()
|
text=True,
|
||||||
mock_openai.return_value = mock_client
|
|
||||||
mock_client.chat.completions.create.return_value = MagicMock(
|
|
||||||
choices=[MagicMock(message=MagicMock(content="response"))]
|
|
||||||
)
|
)
|
||||||
|
assert "my-tool" in result.stdout
|
||||||
|
|
||||||
result = gpt.generate_text("prompt", "gpt-4", 0.7, 500)
|
def test_stdin(self) -> None:
|
||||||
self.assertEqual(result, "response")
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "my_tool.main"],
|
||||||
|
input="hello\n",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "hello" in result.stdout
|
||||||
|
|
||||||
@patch("tools.gpt.gpt.generate_text")
|
def test_file_input(self, tmp_path) -> None:
|
||||||
def test_cli(self, mock_gen):
|
input_file = tmp_path / "input.txt"
|
||||||
mock_gen.return_value = "output"
|
input_file.write_text("test data")
|
||||||
with patch("sys.argv", ["gpt", "prompt"]):
|
result = subprocess.run(
|
||||||
with patch("sys.stdout", new=StringIO()) as out:
|
[sys.executable, "-m", "my_tool.main", str(input_file)],
|
||||||
gpt.main()
|
capture_output=True,
|
||||||
self.assertEqual(out.getvalue().strip(), "output")
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "test data" in result.stdout
|
||||||
```
|
```
|
||||||
|
|
||||||
Pattern: mock external dependencies (APIs, file I/O, subprocesses), test the
|
For unit testing core logic, import the function directly and test it as a
|
||||||
CLI by patching `sys.argv`.
|
pure function.
|
||||||
|
|
||||||
## Build and install
|
## Commands
|
||||||
|
|
||||||
```makefile
|
|
||||||
# Makefile
|
|
||||||
all: build install
|
|
||||||
|
|
||||||
build:
|
|
||||||
uv sync
|
|
||||||
|
|
||||||
install:
|
|
||||||
uv tool install --editable .
|
|
||||||
|
|
||||||
check:
|
|
||||||
uv run ruff format .
|
|
||||||
uv run ruff check . --fix
|
|
||||||
uv run pyright
|
|
||||||
|
|
||||||
test:
|
|
||||||
uv run pytest -v
|
|
||||||
|
|
||||||
clean:
|
|
||||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make all # Sync deps + install to PATH
|
uv sync # Install deps
|
||||||
make check # Format, lint, type-check
|
uv run my-tool --help # Run the tool
|
||||||
make test # Run tests
|
uv run pytest tests/ -v # Run tests
|
||||||
|
uv run ruff check . # Lint
|
||||||
|
uv run ruff format . # Format
|
||||||
```
|
```
|
||||||
|
|
||||||
## Creating a new tool
|
## Registering in castle.yaml
|
||||||
|
|
||||||
Use the toolkit management command:
|
Standalone tools:
|
||||||
|
|
||||||
```bash
|
|
||||||
toolkit create my-tool --description "Does something" --category document
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates the `.py` template, `.md` with frontmatter, and updates
|
|
||||||
`pyproject.toml` with the entry point.
|
|
||||||
|
|
||||||
Or manually:
|
|
||||||
1. Create `tools/<category>/my_tool.py` with the argparse pattern above
|
|
||||||
2. Create `tools/<category>/my_tool.md` with YAML frontmatter
|
|
||||||
3. Add entry to `pyproject.toml` under `[project.scripts]`:
|
|
||||||
```toml
|
|
||||||
my-tool = "tools.category.my_tool:main"
|
|
||||||
```
|
|
||||||
4. Run `make install` to register in PATH
|
|
||||||
|
|
||||||
## Registering in castle
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# castle.yaml
|
|
||||||
components:
|
components:
|
||||||
toolkit:
|
my-tool:
|
||||||
description: Personal utility scripts
|
description: Does something useful
|
||||||
run:
|
tool:
|
||||||
runner: command
|
tool_type: python_standalone
|
||||||
argv: ["toolkit"]
|
category: utilities
|
||||||
cwd: toolkit
|
source: my-tool/
|
||||||
install:
|
install:
|
||||||
path: { alias: toolkit }
|
path:
|
||||||
|
alias: my-tool
|
||||||
```
|
```
|
||||||
|
|
||||||
Tools with `install.path` get the `tool` role. They don't need `expose`,
|
Category tools get one entry per tool, all pointing to the same source:
|
||||||
`proxy`, or `manage` blocks.
|
|
||||||
|
```yaml
|
||||||
|
components:
|
||||||
|
pdf2md:
|
||||||
|
description: Convert PDF files to Markdown
|
||||||
|
tool:
|
||||||
|
tool_type: python_standalone
|
||||||
|
category: document
|
||||||
|
source: tools/document/
|
||||||
|
system_dependencies: [pandoc, poppler-utils]
|
||||||
|
install:
|
||||||
|
path:
|
||||||
|
alias: pdf2md
|
||||||
|
```
|
||||||
|
|
||||||
|
Tools with `install.path` get the **tool** role. They don't need `expose`,
|
||||||
|
`proxy`, or `manage` blocks unless castle also runs them (e.g., scheduled jobs).
|
||||||
|
|
||||||
|
See @docs/component-registry.md for the full manifest reference.
|
||||||
|
|||||||
@@ -422,3 +422,6 @@ uv run ruff format . # Format
|
|||||||
```bash
|
```bash
|
||||||
castle create my-service --type service --description "Does something useful"
|
castle create my-service --type service --description "Does something useful"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See @docs/component-registry.md for manifest fields, role derivation, and
|
||||||
|
the full service lifecycle (enable, logs, gateway reload).
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ components:
|
|||||||
This gives the component both the `frontend` role (from `build`) and the
|
This gives the component both the `frontend` role (from `build`) and the
|
||||||
`service` role (from `expose.http`) during development.
|
`service` role (from `expose.http`) during development.
|
||||||
|
|
||||||
|
See @docs/component-registry.md for the full manifest reference and role
|
||||||
|
derivation rules.
|
||||||
|
|
||||||
## Serving with Caddy
|
## Serving with Caddy
|
||||||
|
|
||||||
For production, serve the static `dist/` output directly from Caddy rather than
|
For production, serve the static `dist/` output directly from Caddy rather than
|
||||||
|
|||||||
Reference in New Issue
Block a user