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():