Implement core SmartTools functionality
- providers.py: Provider abstraction with mock support - tool.py: Tool loading, saving, and wrapper script generation - runner.py: Tool execution engine with prompt building - ui.py: Dialog-based UI for tool management - cli.py: CLI entry point with list/create/edit/delete/test commands ~430 lines of Python as designed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ba18c0feaa
commit
28dac465e6
|
|
@ -0,0 +1,49 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "smarttools"
|
||||
version = "0.1.0"
|
||||
description = "A lightweight personal tool builder for AI-powered CLI commands"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Rob"}
|
||||
]
|
||||
keywords = ["ai", "cli", "tools", "automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"PyYAML>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
smarttools = "smarttools.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/rob/smarttools"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""SmartTools - A lightweight personal tool builder for AI-powered CLI commands."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"""CLI entry point for SmartTools."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolInput
|
||||
from .ui import run_ui
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
"""List all tools."""
|
||||
tools = list_tools()
|
||||
|
||||
if not tools:
|
||||
print("No tools found.")
|
||||
print("Create your first tool with: smarttools create <name>")
|
||||
return 0
|
||||
|
||||
print(f"Available tools ({len(tools)}):\n")
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
print(f" {name}")
|
||||
print(f" {tool.description or 'No description'}")
|
||||
print(f" Provider: {tool.provider}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
"""Create a new tool."""
|
||||
name = args.name
|
||||
|
||||
# Check if already exists
|
||||
existing = load_tool(name)
|
||||
if existing and not args.force:
|
||||
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
|
||||
return 1
|
||||
|
||||
# Create minimal tool
|
||||
tool = Tool(
|
||||
name=name,
|
||||
description=args.description or "",
|
||||
prompt=args.prompt or "Process the following:\n\n{input}",
|
||||
provider=args.provider or "codex",
|
||||
provider_args=args.provider_args or "-p",
|
||||
inputs=[],
|
||||
post_process=None
|
||||
)
|
||||
|
||||
path = save_tool(tool)
|
||||
print(f"Created tool '{name}'")
|
||||
print(f"Config: {path}")
|
||||
print(f"\nEdit the config to customize, then run: {name} -i <file>")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_edit(args):
|
||||
"""Edit a tool (opens in $EDITOR or nano)."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
from .tool import get_tools_dir
|
||||
config_path = get_tools_dir() / args.name / "config.yaml"
|
||||
|
||||
editor = os.environ.get("EDITOR", "nano")
|
||||
|
||||
try:
|
||||
subprocess.run([editor, str(config_path)], check=True)
|
||||
print(f"Tool '{args.name}' updated.")
|
||||
return 0
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Error: Editor failed.")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_delete(args):
|
||||
"""Delete a tool."""
|
||||
if not load_tool(args.name):
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
if not args.force:
|
||||
confirm = input(f"Delete tool '{args.name}'? [y/N] ")
|
||||
if confirm.lower() != 'y':
|
||||
print("Cancelled.")
|
||||
return 0
|
||||
|
||||
if delete_tool(args.name):
|
||||
print(f"Deleted tool '{args.name}'.")
|
||||
return 0
|
||||
else:
|
||||
print(f"Error: Failed to delete '{args.name}'.")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_test(args):
|
||||
"""Test a tool with mock provider."""
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
from .runner import run_tool
|
||||
|
||||
# Read test input
|
||||
if args.input:
|
||||
from pathlib import Path
|
||||
input_text = Path(args.input).read_text()
|
||||
else:
|
||||
print("Enter test input (Ctrl+D to end):")
|
||||
input_text = sys.stdin.read()
|
||||
|
||||
print("\n--- Running with mock provider ---\n")
|
||||
|
||||
output, code = run_tool(
|
||||
tool=tool,
|
||||
input_text=input_text,
|
||||
custom_args={},
|
||||
provider_override="mock",
|
||||
dry_run=args.dry_run,
|
||||
show_prompt=True,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if output:
|
||||
print("\n--- Output ---\n")
|
||||
print(output)
|
||||
|
||||
return code
|
||||
|
||||
|
||||
def cmd_ui(args):
|
||||
"""Launch the interactive UI."""
|
||||
run_ui()
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="smarttools",
|
||||
description="A lightweight personal tool builder for AI-powered CLI commands"
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# No command = launch UI
|
||||
# 'list' command
|
||||
p_list = subparsers.add_parser("list", help="List all tools")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
# 'create' command
|
||||
p_create = subparsers.add_parser("create", help="Create a new tool")
|
||||
p_create.add_argument("name", help="Tool name")
|
||||
p_create.add_argument("-d", "--description", help="Tool description")
|
||||
p_create.add_argument("-p", "--prompt", help="Prompt template")
|
||||
p_create.add_argument("--provider", help="AI provider (default: codex)")
|
||||
p_create.add_argument("--provider-args", help="Provider arguments (default: -p)")
|
||||
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
||||
p_create.set_defaults(func=cmd_create)
|
||||
|
||||
# 'edit' command
|
||||
p_edit = subparsers.add_parser("edit", help="Edit a tool config")
|
||||
p_edit.add_argument("name", help="Tool name")
|
||||
p_edit.set_defaults(func=cmd_edit)
|
||||
|
||||
# 'delete' command
|
||||
p_delete = subparsers.add_parser("delete", help="Delete a tool")
|
||||
p_delete.add_argument("name", help="Tool name")
|
||||
p_delete.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
|
||||
p_delete.set_defaults(func=cmd_delete)
|
||||
|
||||
# 'test' command
|
||||
p_test = subparsers.add_parser("test", help="Test a tool with mock provider")
|
||||
p_test.add_argument("name", help="Tool name")
|
||||
p_test.add_argument("-i", "--input", help="Input file for testing")
|
||||
p_test.add_argument("--dry-run", action="store_true", help="Show prompt only")
|
||||
p_test.set_defaults(func=cmd_test)
|
||||
|
||||
# 'ui' command (explicit)
|
||||
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
|
||||
p_ui.set_defaults(func=cmd_ui)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# If no command, launch UI
|
||||
if args.command is None:
|
||||
return cmd_ui(args)
|
||||
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"""Provider abstraction for AI CLI tools."""
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
"""Result from a provider call."""
|
||||
text: str
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def call_provider(provider: str, args: str, prompt: str, timeout: int = 300) -> ProviderResult:
|
||||
"""
|
||||
Call an AI CLI tool with the given prompt.
|
||||
|
||||
Args:
|
||||
provider: The CLI command (e.g., 'codex', 'claude', 'gemini')
|
||||
args: Additional arguments for the provider (e.g., '-p')
|
||||
prompt: The prompt to send
|
||||
timeout: Maximum execution time in seconds
|
||||
|
||||
Returns:
|
||||
ProviderResult with the response text or error
|
||||
"""
|
||||
# Check if provider exists
|
||||
if not shutil.which(provider):
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider '{provider}' not found. Is it installed and in PATH?"
|
||||
)
|
||||
|
||||
# Build command
|
||||
cmd = f"{provider} {args}".strip()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider exited with code {result.returncode}: {result.stderr}"
|
||||
)
|
||||
|
||||
return ProviderResult(text=result.stdout, success=True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider timed out after {timeout} seconds"
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def mock_provider(prompt: str) -> ProviderResult:
|
||||
"""
|
||||
Return a mock response for testing.
|
||||
|
||||
Args:
|
||||
prompt: The prompt (used for generating mock response)
|
||||
|
||||
Returns:
|
||||
ProviderResult with mock response
|
||||
"""
|
||||
lines = prompt.strip().split('\n')
|
||||
preview = lines[0][:50] + "..." if len(lines[0]) > 50 else lines[0]
|
||||
|
||||
return ProviderResult(
|
||||
text=f"[MOCK RESPONSE]\n"
|
||||
f"Prompt length: {len(prompt)} chars, {len(lines)} lines\n"
|
||||
f"First line: {preview}\n"
|
||||
f"\n"
|
||||
f"This is a mock response. Use a real provider for actual output.",
|
||||
success=True
|
||||
)
|
||||
|
||||
|
||||
def get_provider_func(provider: str):
|
||||
"""
|
||||
Get the appropriate provider function.
|
||||
|
||||
Args:
|
||||
provider: Provider name ('mock' for mock, anything else for real)
|
||||
|
||||
Returns:
|
||||
Callable that takes (prompt) or (provider, args, prompt)
|
||||
"""
|
||||
if provider.lower() == "mock":
|
||||
return lambda prompt, **kwargs: mock_provider(prompt)
|
||||
else:
|
||||
return lambda prompt, args="", **kwargs: call_provider(provider, args, prompt, **kwargs)
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
"""Tool execution engine."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .tool import load_tool, Tool
|
||||
from .providers import call_provider, mock_provider, ProviderResult
|
||||
|
||||
|
||||
def build_prompt(tool: Tool, input_text: str, custom_args: dict) -> str:
|
||||
"""
|
||||
Build the final prompt by substituting placeholders.
|
||||
|
||||
Args:
|
||||
tool: Tool definition
|
||||
input_text: Content from input file or stdin
|
||||
custom_args: Custom argument values
|
||||
|
||||
Returns:
|
||||
Final prompt string
|
||||
"""
|
||||
prompt = tool.prompt
|
||||
|
||||
# Replace {input} placeholder
|
||||
prompt = prompt.replace("{input}", input_text)
|
||||
|
||||
# Replace custom argument placeholders
|
||||
for inp in tool.inputs:
|
||||
value = custom_args.get(inp.name, inp.default)
|
||||
if value is not None:
|
||||
prompt = prompt.replace(f"{{{inp.name}}}", str(value))
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def run_post_process(tool: Tool, output: str) -> str:
|
||||
"""
|
||||
Run post-processing script if defined.
|
||||
|
||||
Args:
|
||||
tool: Tool definition
|
||||
output: Raw output from provider
|
||||
|
||||
Returns:
|
||||
Processed output
|
||||
"""
|
||||
if not tool.post_process:
|
||||
return output
|
||||
|
||||
from .tool import get_tools_dir
|
||||
script_path = get_tools_dir() / tool.name / tool.post_process
|
||||
|
||||
if not script_path.exists():
|
||||
print(f"Warning: Post-process script not found: {script_path}", file=sys.stderr)
|
||||
return output
|
||||
|
||||
# Execute post-process script with output as input
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(script_path)],
|
||||
input=output,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
else:
|
||||
print(f"Warning: Post-process failed: {result.stderr}", file=sys.stderr)
|
||||
return output
|
||||
except Exception as e:
|
||||
print(f"Warning: Post-process error: {e}", file=sys.stderr)
|
||||
return output
|
||||
|
||||
|
||||
def run_tool(
|
||||
tool: Tool,
|
||||
input_text: str,
|
||||
custom_args: dict,
|
||||
provider_override: Optional[str] = None,
|
||||
dry_run: bool = False,
|
||||
show_prompt: bool = False,
|
||||
verbose: bool = False
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Execute a tool.
|
||||
|
||||
Args:
|
||||
tool: Tool definition
|
||||
input_text: Input content
|
||||
custom_args: Custom argument values
|
||||
provider_override: Override the tool's provider
|
||||
dry_run: Just show prompt, don't call provider
|
||||
show_prompt: Show prompt in addition to output
|
||||
verbose: Show debug info
|
||||
|
||||
Returns:
|
||||
Tuple of (output_text, exit_code)
|
||||
"""
|
||||
# Build prompt
|
||||
prompt = build_prompt(tool, input_text, custom_args)
|
||||
|
||||
if verbose:
|
||||
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
|
||||
print(f"[verbose] Provider: {provider_override or tool.provider}", file=sys.stderr)
|
||||
print(f"[verbose] Prompt length: {len(prompt)} chars", file=sys.stderr)
|
||||
|
||||
# Dry run - just show prompt
|
||||
if dry_run:
|
||||
return prompt, 0
|
||||
|
||||
# Show prompt if requested
|
||||
if show_prompt:
|
||||
print("=== PROMPT ===", file=sys.stderr)
|
||||
print(prompt, file=sys.stderr)
|
||||
print("=== END PROMPT ===", file=sys.stderr)
|
||||
|
||||
# Call provider
|
||||
provider = provider_override or tool.provider
|
||||
|
||||
if provider.lower() == "mock":
|
||||
result = mock_provider(prompt)
|
||||
else:
|
||||
result = call_provider(provider, tool.provider_args, prompt)
|
||||
|
||||
if not result.success:
|
||||
print(f"Error: {result.error}", file=sys.stderr)
|
||||
return "", 2 # Exit code 2 for provider errors
|
||||
|
||||
output = result.text
|
||||
|
||||
# Post-process if defined
|
||||
output = run_post_process(tool, output)
|
||||
|
||||
return output, 0
|
||||
|
||||
|
||||
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
|
||||
"""
|
||||
Create an argument parser for a tool.
|
||||
|
||||
Args:
|
||||
tool: Tool definition
|
||||
|
||||
Returns:
|
||||
Configured ArgumentParser
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=tool.name,
|
||||
description=tool.description or f"SmartTools: {tool.name}"
|
||||
)
|
||||
|
||||
# Universal flags
|
||||
parser.add_argument("-i", "--input", dest="input_file",
|
||||
help="Input file (reads from stdin if omitted)")
|
||||
parser.add_argument("-o", "--output", dest="output_file",
|
||||
help="Output file (writes to stdout if omitted)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show prompt without calling AI provider")
|
||||
parser.add_argument("--show-prompt", action="store_true",
|
||||
help="Show prompt in addition to output")
|
||||
parser.add_argument("-p", "--provider",
|
||||
help="Override provider (e.g., --provider mock)")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Show debug information")
|
||||
|
||||
# Tool-specific flags
|
||||
for inp in tool.inputs:
|
||||
parser.add_argument(
|
||||
inp.flag,
|
||||
dest=inp.name,
|
||||
default=inp.default,
|
||||
help=inp.description or f"{inp.name} (default: {inp.default})"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for tool execution via wrapper script."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m smarttools.runner <tool_name> [args...]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = sys.argv[1]
|
||||
tool = load_tool(tool_name)
|
||||
|
||||
if tool is None:
|
||||
print(f"Error: Tool '{tool_name}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse remaining arguments
|
||||
parser = create_argument_parser(tool)
|
||||
args = parser.parse_args(sys.argv[2:])
|
||||
|
||||
# Read input
|
||||
if args.input_file:
|
||||
input_path = Path(args.input_file)
|
||||
if not input_path.exists():
|
||||
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
input_text = input_path.read_text()
|
||||
else:
|
||||
# Read from stdin
|
||||
if sys.stdin.isatty():
|
||||
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
|
||||
input_text = sys.stdin.read()
|
||||
|
||||
# Collect custom args
|
||||
custom_args = {}
|
||||
for inp in tool.inputs:
|
||||
value = getattr(args, inp.name, None)
|
||||
if value is not None:
|
||||
custom_args[inp.name] = value
|
||||
|
||||
# Run tool
|
||||
output, exit_code = run_tool(
|
||||
tool=tool,
|
||||
input_text=input_text,
|
||||
custom_args=custom_args,
|
||||
provider_override=args.provider,
|
||||
dry_run=args.dry_run,
|
||||
show_prompt=args.show_prompt,
|
||||
verbose=args.verbose
|
||||
)
|
||||
|
||||
# Write output
|
||||
if exit_code == 0 and output:
|
||||
if args.output_file:
|
||||
Path(args.output_file).write_text(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"""Tool loading, saving, and management."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# Default tools directory
|
||||
TOOLS_DIR = Path.home() / ".smarttools"
|
||||
|
||||
# Default bin directory for wrapper scripts
|
||||
BIN_DIR = Path.home() / ".local" / "bin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolInput:
|
||||
"""Definition of a custom input argument."""
|
||||
name: str
|
||||
flag: str
|
||||
default: Optional[str] = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
"""A SmartTools tool definition."""
|
||||
name: str
|
||||
description: str
|
||||
prompt: str
|
||||
provider: str
|
||||
provider_args: str = ""
|
||||
inputs: list[ToolInput] = field(default_factory=list)
|
||||
post_process: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Tool":
|
||||
"""Create a Tool from a dictionary (parsed YAML)."""
|
||||
inputs = []
|
||||
for inp in data.get("inputs", []):
|
||||
inputs.append(ToolInput(
|
||||
name=inp["name"],
|
||||
flag=inp.get("flag", f"--{inp['name']}"),
|
||||
default=inp.get("default"),
|
||||
description=inp.get("description", "")
|
||||
))
|
||||
|
||||
return cls(
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
prompt=data["prompt"],
|
||||
provider=data["provider"],
|
||||
provider_args=data.get("provider_args", ""),
|
||||
inputs=inputs,
|
||||
post_process=data.get("post_process")
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert Tool to dictionary for YAML serialization."""
|
||||
data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"prompt": self.prompt,
|
||||
"provider": self.provider,
|
||||
}
|
||||
|
||||
if self.provider_args:
|
||||
data["provider_args"] = self.provider_args
|
||||
|
||||
if self.inputs:
|
||||
data["inputs"] = [
|
||||
{
|
||||
"name": inp.name,
|
||||
"flag": inp.flag,
|
||||
"default": inp.default,
|
||||
"description": inp.description,
|
||||
}
|
||||
for inp in self.inputs
|
||||
]
|
||||
|
||||
if self.post_process:
|
||||
data["post_process"] = self.post_process
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_tools_dir() -> Path:
|
||||
"""Get the tools directory, creating it if needed."""
|
||||
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TOOLS_DIR
|
||||
|
||||
|
||||
def get_bin_dir() -> Path:
|
||||
"""Get the bin directory for wrapper scripts, creating it if needed."""
|
||||
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return BIN_DIR
|
||||
|
||||
|
||||
def list_tools() -> list[str]:
|
||||
"""List all available tools."""
|
||||
tools_dir = get_tools_dir()
|
||||
tools = []
|
||||
|
||||
for item in tools_dir.iterdir():
|
||||
if item.is_dir():
|
||||
config = item / "config.yaml"
|
||||
if config.exists():
|
||||
tools.append(item.name)
|
||||
|
||||
return sorted(tools)
|
||||
|
||||
|
||||
def load_tool(name: str) -> Optional[Tool]:
|
||||
"""
|
||||
Load a tool by name.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Tool object or None if not found
|
||||
"""
|
||||
config_path = get_tools_dir() / name / "config.yaml"
|
||||
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text())
|
||||
return Tool.from_dict(data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_tool(tool: Tool) -> Path:
|
||||
"""
|
||||
Save a tool to disk.
|
||||
|
||||
Args:
|
||||
tool: Tool object to save
|
||||
|
||||
Returns:
|
||||
Path to the saved config file
|
||||
"""
|
||||
tool_dir = get_tools_dir() / tool.name
|
||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = tool_dir / "config.yaml"
|
||||
config_path.write_text(yaml.dump(tool.to_dict(), default_flow_style=False, sort_keys=False))
|
||||
|
||||
# Create wrapper script
|
||||
create_wrapper_script(tool.name)
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
def delete_tool(name: str) -> bool:
|
||||
"""
|
||||
Delete a tool.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
tool_dir = get_tools_dir() / name
|
||||
|
||||
if not tool_dir.exists():
|
||||
return False
|
||||
|
||||
# Remove wrapper script
|
||||
wrapper = get_bin_dir() / name
|
||||
if wrapper.exists():
|
||||
wrapper.unlink()
|
||||
|
||||
# Remove tool directory
|
||||
import shutil
|
||||
shutil.rmtree(tool_dir)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_wrapper_script(name: str) -> Path:
|
||||
"""
|
||||
Create a wrapper script for a tool in ~/.local/bin.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Path to the wrapper script
|
||||
"""
|
||||
bin_dir = get_bin_dir()
|
||||
wrapper_path = bin_dir / name
|
||||
|
||||
script = f"""#!/bin/bash
|
||||
# SmartTools wrapper for '{name}'
|
||||
# Auto-generated - do not edit
|
||||
exec python3 -m smarttools.runner {name} "$@"
|
||||
"""
|
||||
|
||||
wrapper_path.write_text(script)
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
return wrapper_path
|
||||
|
||||
|
||||
def tool_exists(name: str) -> bool:
|
||||
"""Check if a tool exists."""
|
||||
return (get_tools_dir() / name / "config.yaml").exists()
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
"""Dialog-based UI for managing SmartTools."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .tool import Tool, ToolInput, list_tools, load_tool, save_tool, delete_tool, tool_exists
|
||||
|
||||
|
||||
def check_dialog() -> str:
|
||||
"""Check for available dialog program. Returns 'dialog', 'whiptail', or None."""
|
||||
for prog in ["dialog", "whiptail"]:
|
||||
try:
|
||||
subprocess.run([prog, "--version"], capture_output=True, check=False)
|
||||
return prog
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
|
||||
"""
|
||||
Run a dialog command and return (exit_code, output).
|
||||
|
||||
Args:
|
||||
args: Dialog arguments
|
||||
dialog_prog: Dialog program to use
|
||||
|
||||
Returns:
|
||||
Tuple of (exit_code, captured_output)
|
||||
"""
|
||||
cmd = [dialog_prog] + args
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
# dialog outputs to stderr
|
||||
return result.returncode, result.stderr.strip()
|
||||
|
||||
|
||||
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
|
||||
"""
|
||||
Show a menu and return the selected item.
|
||||
|
||||
Args:
|
||||
title: Menu title
|
||||
choices: List of (tag, description) tuples
|
||||
dialog_prog: Dialog program to use
|
||||
|
||||
Returns:
|
||||
Selected tag or None if cancelled
|
||||
"""
|
||||
args = ["--title", title, "--menu", "", "15", "60", str(len(choices))]
|
||||
for tag, desc in choices:
|
||||
args.extend([tag, desc])
|
||||
|
||||
code, output = run_dialog(args, dialog_prog)
|
||||
return output if code == 0 else None
|
||||
|
||||
|
||||
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
|
||||
"""
|
||||
Show an input box and return the entered text.
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
prompt: Prompt text
|
||||
initial: Initial value
|
||||
dialog_prog: Dialog program to use
|
||||
|
||||
Returns:
|
||||
Entered text or None if cancelled
|
||||
"""
|
||||
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
|
||||
code, output = run_dialog(args, dialog_prog)
|
||||
return output if code == 0 else None
|
||||
|
||||
|
||||
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
|
||||
"""
|
||||
Show a text editor for multi-line input.
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
text: Initial text
|
||||
dialog_prog: Dialog program to use
|
||||
|
||||
Returns:
|
||||
Edited text or None if cancelled
|
||||
"""
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
||||
f.write(text)
|
||||
temp_path = f.name
|
||||
|
||||
args = ["--title", title, "--editbox", temp_path, "20", "70"]
|
||||
code, output = run_dialog(args, dialog_prog)
|
||||
|
||||
import os
|
||||
os.unlink(temp_path)
|
||||
|
||||
return output if code == 0 else None
|
||||
|
||||
|
||||
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
|
||||
"""
|
||||
Show a yes/no dialog.
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
text: Question text
|
||||
dialog_prog: Dialog program to use
|
||||
|
||||
Returns:
|
||||
True if yes, False if no
|
||||
"""
|
||||
args = ["--title", title, "--yesno", text, "10", "60"]
|
||||
code, _ = run_dialog(args, dialog_prog)
|
||||
return code == 0
|
||||
|
||||
|
||||
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
|
||||
"""Show a message box."""
|
||||
args = ["--title", title, "--msgbox", text, "10", "60"]
|
||||
run_dialog(args, dialog_prog)
|
||||
|
||||
|
||||
def create_tool_form(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
|
||||
"""
|
||||
Show form for creating/editing a tool.
|
||||
|
||||
Args:
|
||||
dialog_prog: Dialog program to use
|
||||
existing: Existing tool to edit (None for new)
|
||||
|
||||
Returns:
|
||||
Tool object or None if cancelled
|
||||
"""
|
||||
# Get basic info
|
||||
name = show_input(
|
||||
"Tool Name",
|
||||
"Enter tool name (used as command):",
|
||||
existing.name if existing else "",
|
||||
dialog_prog
|
||||
)
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# Check if name already exists (for new tools)
|
||||
if not existing and tool_exists(name):
|
||||
if not show_yesno("Overwrite?", f"Tool '{name}' already exists. Overwrite?", dialog_prog):
|
||||
return None
|
||||
|
||||
description = show_input(
|
||||
"Description",
|
||||
"Enter tool description:",
|
||||
existing.description if existing else "",
|
||||
dialog_prog
|
||||
)
|
||||
if description is None:
|
||||
return None
|
||||
|
||||
provider = show_input(
|
||||
"Provider",
|
||||
"Enter AI provider command (e.g., codex, claude, gemini):",
|
||||
existing.provider if existing else "codex",
|
||||
dialog_prog
|
||||
)
|
||||
if not provider:
|
||||
return None
|
||||
|
||||
provider_args = show_input(
|
||||
"Provider Args",
|
||||
"Enter provider arguments (e.g., -p):",
|
||||
existing.provider_args if existing else "-p",
|
||||
dialog_prog
|
||||
)
|
||||
if provider_args is None:
|
||||
return None
|
||||
|
||||
# Edit prompt
|
||||
default_prompt = existing.prompt if existing else "Process the following input:\n\n{input}"
|
||||
prompt = show_textbox("Prompt Template", default_prompt, dialog_prog)
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
# For simplicity, skip custom inputs in the UI for now
|
||||
# They can be added by editing config.yaml directly
|
||||
inputs = existing.inputs if existing else []
|
||||
|
||||
return Tool(
|
||||
name=name,
|
||||
description=description,
|
||||
prompt=prompt,
|
||||
provider=provider,
|
||||
provider_args=provider_args,
|
||||
inputs=inputs,
|
||||
post_process=existing.post_process if existing else None
|
||||
)
|
||||
|
||||
|
||||
def ui_list_tools(dialog_prog: str):
|
||||
"""Show list of tools."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Tools", "No tools found.\n\nCreate your first tool from the main menu.", dialog_prog)
|
||||
return
|
||||
|
||||
text = "Available tools:\n\n"
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
text += f" {name}: {tool.description or 'No description'}\n"
|
||||
text += f" Provider: {tool.provider}\n\n"
|
||||
|
||||
show_message("Tools", text, dialog_prog)
|
||||
|
||||
|
||||
def ui_create_tool(dialog_prog: str):
|
||||
"""Create a new tool."""
|
||||
tool = create_tool_form(dialog_prog)
|
||||
if tool:
|
||||
path = save_tool(tool)
|
||||
show_message("Success", f"Tool '{tool.name}' created!\n\nConfig: {path}\n\nRun it with: {tool.name} -i <file>", dialog_prog)
|
||||
|
||||
|
||||
def ui_edit_tool(dialog_prog: str):
|
||||
"""Edit an existing tool."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Edit Tool", "No tools found.", dialog_prog)
|
||||
return
|
||||
|
||||
choices = [(name, load_tool(name).description or "No description") for name in tools]
|
||||
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
|
||||
|
||||
if selected:
|
||||
existing = load_tool(selected)
|
||||
if existing:
|
||||
tool = create_tool_form(dialog_prog, existing)
|
||||
if tool:
|
||||
save_tool(tool)
|
||||
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
|
||||
|
||||
|
||||
def ui_delete_tool(dialog_prog: str):
|
||||
"""Delete a tool."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Delete Tool", "No tools found.", dialog_prog)
|
||||
return
|
||||
|
||||
choices = [(name, load_tool(name).description or "No description") for name in tools]
|
||||
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
|
||||
|
||||
if selected:
|
||||
if show_yesno("Confirm Delete", f"Delete tool '{selected}'?\n\nThis cannot be undone.", dialog_prog):
|
||||
if delete_tool(selected):
|
||||
show_message("Deleted", f"Tool '{selected}' deleted.", dialog_prog)
|
||||
else:
|
||||
show_message("Error", f"Failed to delete '{selected}'.", dialog_prog)
|
||||
|
||||
|
||||
def ui_test_tool(dialog_prog: str):
|
||||
"""Test a tool with mock provider."""
|
||||
tools = list_tools()
|
||||
if not tools:
|
||||
show_message("Test Tool", "No tools found.", dialog_prog)
|
||||
return
|
||||
|
||||
choices = [(name, load_tool(name).description or "No description") for name in tools]
|
||||
selected = show_menu("Select Tool to Test", choices, dialog_prog)
|
||||
|
||||
if selected:
|
||||
tool = load_tool(selected)
|
||||
if tool:
|
||||
# Get test input
|
||||
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
|
||||
if test_input:
|
||||
from .runner import run_tool
|
||||
output, code = run_tool(
|
||||
tool=tool,
|
||||
input_text=test_input,
|
||||
custom_args={},
|
||||
provider_override="mock",
|
||||
dry_run=False,
|
||||
show_prompt=False,
|
||||
verbose=False
|
||||
)
|
||||
show_message(
|
||||
"Test Result",
|
||||
f"Exit code: {code}\n\n--- Output ---\n{output[:500]}",
|
||||
dialog_prog
|
||||
)
|
||||
|
||||
|
||||
def main_menu(dialog_prog: str):
|
||||
"""Show the main menu."""
|
||||
while True:
|
||||
choice = show_menu(
|
||||
"SmartTools Manager",
|
||||
[
|
||||
("list", "List all tools"),
|
||||
("create", "Create new tool"),
|
||||
("edit", "Edit existing tool"),
|
||||
("delete", "Delete tool"),
|
||||
("test", "Test tool (mock provider)"),
|
||||
("exit", "Exit"),
|
||||
],
|
||||
dialog_prog
|
||||
)
|
||||
|
||||
if choice is None or choice == "exit":
|
||||
break
|
||||
elif choice == "list":
|
||||
ui_list_tools(dialog_prog)
|
||||
elif choice == "create":
|
||||
ui_create_tool(dialog_prog)
|
||||
elif choice == "edit":
|
||||
ui_edit_tool(dialog_prog)
|
||||
elif choice == "delete":
|
||||
ui_delete_tool(dialog_prog)
|
||||
elif choice == "test":
|
||||
ui_test_tool(dialog_prog)
|
||||
|
||||
|
||||
def run_ui():
|
||||
"""Entry point for the UI."""
|
||||
dialog_prog = check_dialog()
|
||||
|
||||
if not dialog_prog:
|
||||
print("Error: Neither 'dialog' nor 'whiptail' found.", file=sys.stderr)
|
||||
print("Install with: sudo apt install dialog", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
main_menu(dialog_prog)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
# Clear screen on exit
|
||||
subprocess.run(["clear"], check=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_ui()
|
||||
Loading…
Reference in New Issue