orchestrated-discussions/.venv/lib/python3.12/site-packages/smarttools/cli.py

736 lines
25 KiB
Python

"""CLI entry point for SmartTools."""
import argparse
import sys
from pathlib import Path
from . import __version__
from .tool import list_tools, load_tool, save_tool, delete_tool, Tool, ToolArgument, PromptStep, CodeStep
from .ui import run_ui
from .providers import load_providers, add_provider, delete_provider, Provider, call_provider
def cmd_list(args):
"""List all tools."""
tools = list_tools()
if not tools:
print("No tools found.")
print("Create your first tool with: smarttools ui")
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'}")
# Show arguments
if tool.arguments:
args_str = ", ".join(arg.flag for arg in tool.arguments)
print(f" Arguments: {args_str}")
# Show steps
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
elif isinstance(step, CodeStep):
step_info.append("CODE")
print(f" Steps: {' -> '.join(step_info)}")
print()
return 0
def cmd_create(args):
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
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 a tool with a single prompt step
steps = []
if args.prompt:
steps.append(PromptStep(
prompt=args.prompt,
provider=args.provider or "mock",
output_var="response"
))
tool = Tool(
name=name,
description=args.description or "",
arguments=[],
steps=steps,
output="{response}" if steps else "{input}"
)
path = save_tool(tool)
print(f"Created tool '{name}'")
print(f"Config: {path}")
print(f"\nUse 'smarttools ui' to add arguments, steps, and customize.")
print(f"Or run: {name} < input.txt")
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_run(args):
"""Run a tool."""
from pathlib import Path
from .runner import run_tool
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
return 1
# Read input
if args.input:
# Read from file
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
return 1
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args from remaining arguments
custom_args = {}
if args.tool_args:
# Parse tool-specific arguments
i = 0
while i < len(args.tool_args):
arg = args.tool_args[i]
if arg.startswith('--'):
key = arg[2:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
elif arg.startswith('-'):
key = arg[1:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
else:
i += 1
# Run tool
output, 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 code == 0 and output:
if args.output:
Path(args.output).write_text(output)
else:
print(output)
return code
def cmd_ui(args):
"""Launch the interactive UI."""
run_ui()
return 0
def cmd_refresh(args):
"""Refresh all wrapper scripts with the current Python path."""
from .tool import list_tools, create_wrapper_script
tools = list_tools()
if not tools:
print("No tools found.")
return 0
print(f"Refreshing wrapper scripts for {len(tools)} tools...")
for name in tools:
path = create_wrapper_script(name)
print(f" {name} -> {path}")
print("\nDone. Wrapper scripts updated to use current Python interpreter.")
return 0
def cmd_docs(args):
"""View or edit tool documentation."""
import os
import subprocess
from .tool import get_tools_dir
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
readme_path = get_tools_dir() / args.name / "README.md"
if args.edit:
# Edit/create README
editor = os.environ.get("EDITOR", "nano")
# Create a template if README doesn't exist
if not readme_path.exists():
template = f"""# {args.name}
{tool.description or 'No description provided.'}
## Usage
```bash
echo "input" | {args.name}
```
## Arguments
| Flag | Default | Description |
|------|---------|-------------|
"""
for arg in tool.arguments:
template += f"| `{arg.flag}` | {arg.default or ''} | {arg.description or ''} |\n"
template += """
## Examples
```bash
# Example 1
```
## Requirements
- List any dependencies here
"""
readme_path.write_text(template)
print(f"Created template: {readme_path}")
try:
subprocess.run([editor, str(readme_path)], check=True)
print(f"Documentation updated: {readme_path}")
return 0
except subprocess.CalledProcessError:
print("Error: Editor failed.")
return 1
except FileNotFoundError:
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
return 1
else:
# View README
if not readme_path.exists():
print(f"No documentation found for '{args.name}'.")
print(f"Create it with: smarttools docs {args.name} --edit")
return 1
print(readme_path.read_text())
return 0
PROVIDER_INSTALL_INFO = {
"claude": {
"group": "Anthropic Claude",
"install_cmd": "npm install -g @anthropic-ai/claude-code",
"requires": "Node.js 18+ and npm",
"setup": "Run 'claude' - opens browser for sign-in (auto-saves auth tokens)",
"cost": "Pay-per-use (billed to your Anthropic account)",
"variants": ["claude", "claude-haiku", "claude-opus", "claude-sonnet"],
},
"codex": {
"group": "OpenAI Codex",
"install_cmd": "npm install -g @openai/codex",
"requires": "Node.js 18+ and npm",
"setup": "Run 'codex' - opens browser for sign-in (auto-saves auth tokens)",
"cost": "Pay-per-use (billed to your OpenAI account)",
"variants": ["codex"],
},
"gemini": {
"group": "Google Gemini",
"install_cmd": "npm install -g @google/gemini-cli",
"requires": "Node.js 18+ and npm",
"setup": "Run 'gemini' - opens browser for Google sign-in",
"cost": "Free tier available, pay-per-use for more",
"variants": ["gemini", "gemini-flash"],
},
"opencode": {
"group": "OpenCode (75+ providers)",
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
"requires": "curl, bash",
"setup": "Run 'opencode' - opens browser to connect more providers",
"cost": "4 FREE models included (Big Pickle, GLM-4.7, Grok Code Fast 1, MiniMax M2.1), 75+ more available",
"variants": ["opencode-pickle", "opencode-deepseek", "opencode-nano", "opencode-reasoner", "opencode-grok"],
},
"ollama": {
"group": "Ollama (Local LLMs)",
"install_cmd": "curl -fsSL https://ollama.ai/install.sh | bash",
"requires": "curl, bash, 8GB+ RAM (GPU recommended)",
"setup": "Run 'ollama pull llama3' to download a model, then add provider",
"cost": "FREE (runs entirely on your machine)",
"variants": [],
"custom": True,
"post_install_note": "After installing, add the provider:\n smarttools providers add ollama 'ollama run llama3' -d 'Local Llama 3'",
},
}
def cmd_providers(args):
"""Manage AI providers."""
import shutil
import subprocess
if args.providers_cmd == "install":
print("=" * 60)
print("SmartTools Provider Installation Guide")
print("=" * 60)
print()
# Check what's already installed
providers = load_providers()
installed_groups = set()
for p in providers:
if p.name.lower() == "mock":
continue
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
if shutil.which(cmd_expanded) or Path(cmd_expanded).exists():
# Find which group this belongs to
for group, info in PROVIDER_INSTALL_INFO.items():
if p.name in info.get("variants", []):
installed_groups.add(group)
# Show available provider groups
print("Available AI Provider Groups:\n")
options = []
for i, (key, info) in enumerate(PROVIDER_INSTALL_INFO.items(), 1):
status = "[INSTALLED]" if key in installed_groups else ""
print(f" {i}. {info['group']} {status}")
print(f" Cost: {info['cost']}")
print(f" Models: {', '.join(info['variants']) if info['variants'] else 'Custom'}")
print()
options.append(key)
print(" 0. Cancel")
print()
try:
choice = input("Select a provider to install (1-{}, or 0 to cancel): ".format(len(options)))
choice = int(choice)
except (ValueError, EOFError):
print("Cancelled.")
return 0
if choice == 0 or choice > len(options):
print("Cancelled.")
return 0
selected = options[choice - 1]
info = PROVIDER_INSTALL_INFO[selected]
print()
print("=" * 60)
print(f"Installing: {info['group']}")
print("=" * 60)
print()
print(f"Requirements: {info['requires']}")
print(f"Install command: {info['install_cmd']}")
print(f"Post-install: {info['setup']}")
print()
try:
confirm = input("Run installation command? (y/N): ").strip().lower()
except EOFError:
confirm = "n"
if confirm == "y":
print()
print(f"Running: {info['install_cmd']}")
print("-" * 40)
result = subprocess.run(info['install_cmd'], shell=True)
print("-" * 40)
if result.returncode == 0:
# Refresh PATH to pick up newly installed tools
import os
new_paths = []
# Common install locations that might have been added
potential_paths = [
Path.home() / ".opencode" / "bin", # OpenCode
Path.home() / ".local" / "bin", # pip/pipx installs
Path("/usr/local/bin"), # Ollama, system installs
]
# Also try to get npm global bin path
try:
npm_result = subprocess.run(
["npm", "bin", "-g"],
capture_output=True, text=True, timeout=5
)
if npm_result.returncode == 0:
npm_bin = npm_result.stdout.strip()
if npm_bin:
potential_paths.append(Path(npm_bin))
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
current_path = os.environ.get("PATH", "")
for p in potential_paths:
if p.exists() and str(p) not in current_path:
new_paths.append(str(p))
if new_paths:
os.environ["PATH"] = ":".join(new_paths) + ":" + current_path
print()
print("Installation completed!")
print()
print("IMPORTANT: Refresh your shell PATH before continuing:")
print(" source ~/.bashrc")
print()
print(f"Next steps:")
print(f" 1. source ~/.bashrc (required!)")
print(f" 2. {info['setup']}")
if info.get('post_install_note'):
print(f" 3. {info['post_install_note']}")
print(f" 4. Test with: smarttools providers test {selected}")
else:
print(f" 3. Test with: smarttools providers test {info['variants'][0] if info['variants'] else selected}")
else:
print()
print(f"Installation failed (exit code {result.returncode})")
print("Try running the command manually:")
print(f" {info['install_cmd']}")
else:
print()
print("To install manually, run:")
print(f" {info['install_cmd']}")
print()
print(f"Then: {info['setup']}")
return 0
elif args.providers_cmd == "list":
providers = load_providers()
print(f"Configured providers ({len(providers)}):\n")
for p in providers:
# Mock provider is always available
if p.name.lower() == "mock":
print(f" [+] {p.name}")
print(f" Command: (built-in)")
print(f" Status: OK (always available)")
if p.description:
print(f" Info: {p.description}")
print()
continue
# Check if command exists
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
status = "OK" if exists else "NOT FOUND"
status_icon = "+" if exists else "-"
print(f" [{status_icon}] {p.name}")
print(f" Command: {p.command}")
print(f" Status: {status}")
if p.description:
print(f" Info: {p.description}")
print()
return 0
elif args.providers_cmd == "add":
name = args.name
command = args.command
description = args.description or ""
provider = Provider(name, command, description)
add_provider(provider)
print(f"Provider '{name}' added/updated.")
return 0
elif args.providers_cmd == "remove":
name = args.name
if delete_provider(name):
print(f"Provider '{name}' removed.")
return 0
else:
print(f"Provider '{name}' not found.")
return 1
elif args.providers_cmd == "test":
name = args.name
print(f"Testing provider '{name}'...")
result = call_provider(name, "Say 'hello' and nothing else.", timeout=30)
if result.success:
print(f"SUCCESS: {result.text[:200]}...")
else:
print(f"FAILED: {result.error}")
return 0 if result.success else 1
elif args.providers_cmd == "check":
providers = load_providers()
print("Checking all providers...\n")
available = []
missing = []
for p in providers:
# Mock provider is always available (handled specially)
if p.name.lower() == "mock":
available.append(p.name)
print(f" [+] {p.name}: OK (built-in)")
continue
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
if exists:
available.append(p.name)
print(f" [+] {p.name}: OK")
else:
missing.append(p.name)
print(f" [-] {p.name}: NOT FOUND ({cmd_parts})")
print(f"\nSummary: {len(available)} available, {len(missing)} missing")
if len(available) == 1 and available[0] == "mock":
print(f"\nNo real AI providers found. Install one of these:")
print(f" - claude: npm install -g @anthropic-ai/claude-cli")
print(f" - codex: pip install openai-codex")
print(f" - gemini: pip install google-generative-ai")
print(f"\nMeanwhile, use mock provider for testing:")
print(f" echo 'test' | summarize --provider mock")
elif missing:
print(f"\nAvailable providers: {', '.join(available)}")
return 0
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: mock)")
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)
# 'run' command
p_run = subparsers.add_parser("run", help="Run a tool")
p_run.add_argument("name", help="Tool name")
p_run.add_argument("-i", "--input", help="Input file (reads from stdin if piped)")
p_run.add_argument("-o", "--output", help="Output file (writes to stdout if omitted)")
p_run.add_argument("--stdin", action="store_true", help="Read input interactively (type then Ctrl+D)")
p_run.add_argument("-p", "--provider", help="Override provider")
p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output")
p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information")
p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments")
p_run.set_defaults(func=cmd_run)
# 'ui' command (explicit)
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
p_ui.set_defaults(func=cmd_ui)
# 'refresh' command
p_refresh = subparsers.add_parser("refresh", help="Refresh all wrapper scripts")
p_refresh.set_defaults(func=cmd_refresh)
# 'docs' command
p_docs = subparsers.add_parser("docs", help="View or edit tool documentation")
p_docs.add_argument("name", help="Tool name")
p_docs.add_argument("-e", "--edit", action="store_true", help="Edit/create README in $EDITOR")
p_docs.set_defaults(func=cmd_docs)
# 'providers' command
p_providers = subparsers.add_parser("providers", help="Manage AI providers")
providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands")
# providers list
p_prov_list = providers_sub.add_parser("list", help="List all providers and their status")
p_prov_list.set_defaults(func=cmd_providers)
# providers check
p_prov_check = providers_sub.add_parser("check", help="Check which providers are available")
p_prov_check.set_defaults(func=cmd_providers)
# providers install
p_prov_install = providers_sub.add_parser("install", help="Interactive guide to install AI providers")
p_prov_install.set_defaults(func=cmd_providers)
# providers add
p_prov_add = providers_sub.add_parser("add", help="Add or update a provider")
p_prov_add.add_argument("name", help="Provider name")
p_prov_add.add_argument("command", help="Command to run (e.g., 'claude -p')")
p_prov_add.add_argument("-d", "--description", help="Provider description")
p_prov_add.set_defaults(func=cmd_providers)
# providers remove
p_prov_remove = providers_sub.add_parser("remove", help="Remove a provider")
p_prov_remove.add_argument("name", help="Provider name")
p_prov_remove.set_defaults(func=cmd_providers)
# providers test
p_prov_test = providers_sub.add_parser("test", help="Test a provider")
p_prov_test.add_argument("name", help="Provider name")
p_prov_test.set_defaults(func=cmd_providers)
# Default for providers with no subcommand
p_providers.set_defaults(func=lambda args: cmd_providers(args) if args.providers_cmd else (setattr(args, 'providers_cmd', 'list') or cmd_providers(args)))
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())