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:
2026-02-21 00:09:34 -08:00
parent f39a551aad
commit 08c6f3fa83
34 changed files with 1748 additions and 296 deletions

View File

@@ -17,8 +17,10 @@ from castle_cli.manifest import (
ProxySpec,
RunPythonUvTool,
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:
@@ -41,11 +43,16 @@ def run_create(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
proj_type = args.type
category = getattr(args, "category", None)
if name in config.components:
print(f"Error: component '{name}' already exists in castle.yaml")
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
if project_dir.exists():
print(f"Error: directory '{name}' already exists")
@@ -95,6 +102,10 @@ def run_create(args: argparse.Namespace) -> int:
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
tool=ToolSpec(
tool_type=ToolType.PYTHON_STANDALONE,
source=f"{name}/",
),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
@@ -119,3 +130,65 @@ def run_create(args: argparse.Namespace) -> int:
print(f" castle test {name}")
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

View File

@@ -82,6 +82,15 @@ def run_info(args: argparse.Namespace) -> int:
pi = manifest.install.path
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
if manifest.tags:
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")

View File

@@ -190,6 +190,7 @@ WorkingDirectory={working_dir}
ExecStart={exec_start}
{env_lines}Restart={restart}
RestartSec={restart_sec}
SuccessExitStatus=143
"""
if sd and sd.no_new_privileges:

View File

@@ -46,13 +46,11 @@ def _tool_list() -> int:
print(f"\n{BOLD}{CYAN}{category}{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, manifest in sorted(items):
tt = manifest.tool.tool_type.value
ver = manifest.tool.version
desc = manifest.description or ""
deps = ""
if manifest.tool.system_dependencies:
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()
return 0
@@ -76,13 +74,10 @@ def _tool_info(name: str) -> int:
print(f"{'' * 40}")
if 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}version{RESET}: {t.version}")
if 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:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
@@ -106,7 +101,9 @@ def _tool_info(name: str) -> int:
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."""
source_path = root / source
if source_path.is_file():

View File

@@ -46,6 +46,10 @@ def build_parser() -> argparse.ArgumentParser:
create_parser.add_argument(
"--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
info_parser = subparsers.add_parser("info", help="Show component details")

View File

@@ -170,17 +170,15 @@ class InstallSpec(BaseModel):
class ToolType(str, Enum):
PYTHON_UV = "python_uv"
PYTHON_STANDALONE = "python_standalone"
SCRIPT = "script"
class ToolSpec(BaseModel):
tool_type: ToolType = ToolType.PYTHON_UV
tool_type: ToolType = ToolType.PYTHON_STANDALONE
category: str | None = None
version: str = "1.0.0"
source: str | None = None
entry_point: str | None = None
system_dependencies: list[str] = Field(default_factory=list)

View File

@@ -5,6 +5,99 @@ from __future__ import annotations
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(
project_dir: Path,
name: str,