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:
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,10 +97,9 @@ class TestRoleDerivation:
|
||||
m = ComponentManifest(
|
||||
id="docx2md",
|
||||
tool=ToolSpec(
|
||||
tool_type=ToolType.PYTHON_UV,
|
||||
tool_type=ToolType.PYTHON_STANDALONE,
|
||||
category="document",
|
||||
source="tools/document/docx2md.py",
|
||||
entry_point="tools.document.docx2md:main",
|
||||
source="tools/document/",
|
||||
),
|
||||
)
|
||||
assert Role.TOOL in m.roles
|
||||
|
||||
Reference in New Issue
Block a user