From 28dac465e6ac4b530d39c7efca60ad7135a78073 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 28 Nov 2025 04:45:39 -0400 Subject: [PATCH] Implement core SmartTools functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pyproject.toml | 49 ++++++ src/smarttools/__init__.py | 3 + src/smarttools/cli.py | 207 ++++++++++++++++++++++ src/smarttools/providers.py | 110 ++++++++++++ src/smarttools/runner.py | 241 +++++++++++++++++++++++++ src/smarttools/tool.py | 214 ++++++++++++++++++++++ src/smarttools/ui.py | 343 ++++++++++++++++++++++++++++++++++++ 7 files changed, 1167 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/smarttools/__init__.py create mode 100644 src/smarttools/cli.py create mode 100644 src/smarttools/providers.py create mode 100644 src/smarttools/runner.py create mode 100644 src/smarttools/tool.py create mode 100644 src/smarttools/ui.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cf07c2e --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/smarttools/__init__.py b/src/smarttools/__init__.py new file mode 100644 index 0000000..11f3f70 --- /dev/null +++ b/src/smarttools/__init__.py @@ -0,0 +1,3 @@ +"""SmartTools - A lightweight personal tool builder for AI-powered CLI commands.""" + +__version__ = "0.1.0" diff --git a/src/smarttools/cli.py b/src/smarttools/cli.py new file mode 100644 index 0000000..33a05c8 --- /dev/null +++ b/src/smarttools/cli.py @@ -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 ") + 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 ") + + 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()) diff --git a/src/smarttools/providers.py b/src/smarttools/providers.py new file mode 100644 index 0000000..e4247e0 --- /dev/null +++ b/src/smarttools/providers.py @@ -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) diff --git a/src/smarttools/runner.py b/src/smarttools/runner.py new file mode 100644 index 0000000..0ab0f7f --- /dev/null +++ b/src/smarttools/runner.py @@ -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 [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() diff --git a/src/smarttools/tool.py b/src/smarttools/tool.py new file mode 100644 index 0000000..7b35730 --- /dev/null +++ b/src/smarttools/tool.py @@ -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() diff --git a/src/smarttools/ui.py b/src/smarttools/ui.py new file mode 100644 index 0000000..2dd9190 --- /dev/null +++ b/src/smarttools/ui.py @@ -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 ", 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()