"""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 from .config import load_config, save_config, set_registry_token from .manifest import ( load_manifest, save_manifest, create_manifest, find_manifest, Manifest, Dependency, MANIFEST_FILENAME ) from .resolver import ( resolve_tool, find_tool, install_from_registry, uninstall_tool, list_installed_tools, ToolNotFoundError, ToolSpec ) 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 # ------------------------------------------------------------------------- # Registry Commands # ------------------------------------------------------------------------- def cmd_registry(args): """Handle registry subcommands.""" from .registry_client import ( RegistryClient, RegistryError, RateLimitError, get_client, search, install_tool as registry_install ) if args.registry_cmd == "search": try: client = get_client() results = client.search_tools( query=args.query, category=args.category, per_page=args.limit or 20 ) if not results.data: print(f"No tools found matching '{args.query}'") return 0 print(f"Found {results.total} tools:\n") for tool in results.data: owner = tool.get("owner", "") name = tool.get("name", "") version = tool.get("version", "") desc = tool.get("description", "") downloads = tool.get("downloads", 0) print(f" {owner}/{name} v{version}") print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}") print(f" Downloads: {downloads}") print() if results.total_pages > 1: print(f"Showing page {results.page}/{results.total_pages}") except RegistryError as e: if e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) print("Check your internet connection or try again later.", file=sys.stderr) elif e.code == "RATE_LIMITED": print(f"Rate limited. Please wait and try again.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error searching registry: {e}", file=sys.stderr) print("If the problem persists, check: smarttools config show", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "install": tool_spec = args.tool version = args.version print(f"Installing {tool_spec}...") try: resolved = install_from_registry(tool_spec, version) print(f"Installed: {resolved.full_name}@{resolved.version}") print(f"Location: {resolved.path}") # Show wrapper info from .tool import BIN_DIR wrapper_name = resolved.tool.name if resolved.owner: # Check for collision short_wrapper = BIN_DIR / resolved.tool.name if short_wrapper.exists(): wrapper_name = f"{resolved.owner}-{resolved.tool.name}" print(f"\nRun with: {wrapper_name}") except RegistryError as e: if e.code == "TOOL_NOT_FOUND": print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) print(f"Try: smarttools registry search {tool_spec.split('/')[-1]}", file=sys.stderr) elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": print(f"Error: {e.message}", file=sys.stderr) if e.details and "available_versions" in e.details: versions = e.details["available_versions"] print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr) if e.details.get("latest_stable"): print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) print("Check your internet connection or try again later.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error installing tool: {e}", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "uninstall": tool_spec = args.tool print(f"Uninstalling {tool_spec}...") if uninstall_tool(tool_spec): print(f"Uninstalled: {tool_spec}") else: print(f"Tool '{tool_spec}' not found", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "info": tool_spec = args.tool try: # Parse the tool spec parsed = ToolSpec.parse(tool_spec) owner = parsed.owner or "official" client = get_client() tool_info = client.get_tool(owner, parsed.name) print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}") print("=" * 50) print(f"Description: {tool_info.description}") print(f"Category: {tool_info.category}") print(f"Tags: {', '.join(tool_info.tags)}") print(f"Downloads: {tool_info.downloads}") print(f"Published: {tool_info.published_at}") if tool_info.deprecated: print() print(f"DEPRECATED: {tool_info.deprecated_message}") if tool_info.replacement: print(f"Use instead: {tool_info.replacement}") # Show versions versions = client.get_tool_versions(owner, parsed.name) if versions: print(f"\nVersions: {', '.join(versions[:5])}") if len(versions) > 5: print(f" ...and {len(versions) - 5} more") print(f"\nInstall: smarttools registry install {tool_info.owner}/{tool_info.name}") except RegistryError as e: if e.code == "TOOL_NOT_FOUND": print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr) print(f"Try: smarttools registry search {parsed.name}", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) print("Check your internet connection or try again later.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error fetching tool info: {e}", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "update": print("Updating registry index...") try: client = get_client() index = client.get_index(force_refresh=True) tool_count = index.get("tool_count", len(index.get("tools", []))) generated = index.get("generated_at", "unknown") print(f"Index updated: {tool_count} tools") print(f"Generated: {generated}") except RegistryError as e: if e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) print("Check your internet connection or try again later.", file=sys.stderr) elif e.code == "RATE_LIMITED": print("Rate limited. Please wait a moment and try again.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error updating index: {e}", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "publish": # Read tool from current directory or specified path tool_path = Path(args.path) if args.path else Path.cwd() if tool_path.is_dir(): config_path = tool_path / "config.yaml" else: config_path = tool_path tool_path = config_path.parent if not config_path.exists(): print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr) return 1 # Read config import yaml config_yaml = config_path.read_text() # Read README if exists readme_path = tool_path / "README.md" readme = readme_path.read_text() if readme_path.exists() else "" # Validate try: data = yaml.safe_load(config_yaml) name = data.get("name", "") version = data.get("version", "") if not name or not version: print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr) return 1 except yaml.YAMLError as e: print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr) return 1 if args.dry_run: print("Dry run - validating only") print() print(f"Would publish:") print(f" Name: {name}") print(f" Version: {version}") print(f" Config: {len(config_yaml)} bytes") print(f" README: {len(readme)} bytes") return 0 # Check for token config = load_config() if not config.registry.token: print("No registry token configured.") print() print("1. Register at: https://gitea.brrd.tech/registry/register") print("2. Generate a token from your dashboard") print("3. Enter your token below") print() try: token = input("Registry token: ").strip() if not token: print("Cancelled.") return 1 set_registry_token(token) print("Token saved.") except (EOFError, KeyboardInterrupt): print("\nCancelled.") return 1 print(f"Publishing {name}@{version}...") try: client = get_client() result = client.publish_tool(config_yaml, readme) pr_url = result.get("pr_url", "") status = result.get("status", "") if status == "published" or result.get("version"): print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}") elif pr_url: print(f"PR created: {pr_url}") print("Your tool is pending review.") else: print("Published successfully!") # Show suggestions if provided (from Phase 6 smart features) suggestions = result.get("suggestions", {}) if suggestions: print() # Category suggestion cat_suggestion = suggestions.get("category") if cat_suggestion and cat_suggestion.get("suggested"): confidence = cat_suggestion.get("confidence", 0) print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)") # Similar tools warning similar = suggestions.get("similar_tools", []) if similar: print("Similar existing tools:") for tool in similar[:3]: similarity = tool.get("similarity", 0) print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)") except RegistryError as e: if e.code == "UNAUTHORIZED": print("Authentication failed.", file=sys.stderr) print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr) elif e.code == "INVALID_CONFIG": print(f"Invalid tool config: {e.message}", file=sys.stderr) print("Check your config.yaml for errors.", file=sys.stderr) elif e.code == "VERSION_EXISTS": print(f"Version already exists: {e.message}", file=sys.stderr) print("Bump the version in config.yaml and try again.", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) print("Check your internet connection or try again later.", file=sys.stderr) elif e.code == "RATE_LIMITED": print("Rate limited. Please wait a moment and try again.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error publishing: {e}", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "my-tools": try: client = get_client() tools = client.get_my_tools() if not tools: print("You haven't published any tools yet.") print("Publish your first tool with: smarttools registry publish") return 0 print(f"Your published tools ({len(tools)}):\n") for tool in tools: status = "[DEPRECATED]" if tool.deprecated else "" print(f" {tool.owner}/{tool.name} v{tool.version} {status}") print(f" Downloads: {tool.downloads}") print() except RegistryError as e: if e.code == "UNAUTHORIZED": print("Not logged in. Set your registry token first:", file=sys.stderr) print(" smarttools config set-token ", file=sys.stderr) print() print("Don't have a token? Register at the registry website.", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) print("Check your internet connection or try again later.", file=sys.stderr) elif e.code == "RATE_LIMITED": print("Rate limited. Please wait a moment and try again.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 return 0 elif args.registry_cmd == "browse": # Launch TUI browser try: from .ui_registry import run_registry_browser return run_registry_browser() except ImportError: print("TUI browser requires urwid. Install with:", file=sys.stderr) print(" pip install 'smarttools[tui]'", file=sys.stderr) print() print("Or search from command line:", file=sys.stderr) print(" smarttools registry search ", file=sys.stderr) return 1 else: # Default: show registry help print("Registry commands:") print(" search Search for tools") print(" install Install a tool") print(" uninstall Uninstall a tool") print(" info Show tool information") print(" update Update local index cache") print(" publish [path] Publish a tool") print(" my-tools List your published tools") print(" browse Browse tools (TUI)") return 0 # ------------------------------------------------------------------------- # Project Commands # ------------------------------------------------------------------------- def cmd_deps(args): """Show project dependencies from smarttools.yaml.""" manifest = load_manifest() if manifest is None: print("No smarttools.yaml found in current project.") print("Create one with: smarttools init") return 1 print(f"Project: {manifest.name} v{manifest.version}") print() if not manifest.dependencies: print("No dependencies defined.") print("Add one with: smarttools add ") return 0 print(f"Dependencies ({len(manifest.dependencies)}):") print() for dep in manifest.dependencies: # Check if installed installed = find_tool(dep.name) status = "[installed]" if installed else "[not installed]" print(f" {dep.name}") print(f" Version: {dep.version}") print(f" Status: {status}") print() if manifest.overrides: print("Overrides:") for name, override in manifest.overrides.items(): if override.provider: print(f" {name}: provider={override.provider}") return 0 def cmd_install_deps(args): """Install dependencies from smarttools.yaml.""" from .registry_client import get_client, RegistryError manifest = load_manifest() if manifest is None: print("No smarttools.yaml found in current project.") print("Create one with: smarttools init") return 1 if not manifest.dependencies: print("No dependencies to install.") return 0 print(f"Installing dependencies for {manifest.name}...") print() failed = [] installed = [] for i, dep in enumerate(manifest.dependencies, 1): print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}") # Check if already installed existing = find_tool(dep.name) if existing: print(f" Already installed: {existing.full_name}") installed.append(dep.name) continue try: print(f" Downloading...") resolved = install_from_registry(dep.name, dep.version) print(f" Installed: {resolved.full_name}@{resolved.version}") installed.append(dep.name) except RegistryError as e: if e.code == "TOOL_NOT_FOUND": print(f" Not found in registry") elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE": print(f" Version {dep.version} not available") elif e.code == "CONNECTION_ERROR": print(f" Connection failed (check network)") else: print(f" Failed: {e.message}") failed.append(dep.name) except Exception as e: print(f" Failed: {e}") failed.append(dep.name) print() print(f"Installed {len(installed)} tools") if failed: print(f"Failed: {', '.join(failed)}") return 1 return 0 def cmd_add(args): """Add a tool to project dependencies.""" tool_spec = args.tool version = args.version or "*" # Find or create manifest manifest_path = find_manifest() if manifest_path: manifest = load_manifest(manifest_path) else: # Create in current directory manifest = create_manifest(name=Path.cwd().name) manifest_path = Path.cwd() / MANIFEST_FILENAME # Parse tool spec parsed = ToolSpec.parse(tool_spec) full_name = parsed.full_name # Add dependency manifest.add_dependency(full_name, version) # Save save_manifest(manifest, manifest_path) print(f"Added {full_name}@{version} to {manifest_path.name}") # Install if requested if not args.no_install: from .registry_client import RegistryError print(f"Installing {full_name}...") try: resolved = install_from_registry(tool_spec, version if version != "*" else None) print(f"Installed: {resolved.full_name}@{resolved.version}") except RegistryError as e: if e.code == "TOOL_NOT_FOUND": print(f"Tool not found in registry.", file=sys.stderr) print(f"It's been added to your dependencies - you can install it manually later.", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print(f"Could not connect to registry.", file=sys.stderr) print("Run 'smarttools install' to try again later.", file=sys.stderr) else: print(f"Install failed: {e.message}", file=sys.stderr) print("Run 'smarttools install' to try again.", file=sys.stderr) except Exception as e: print(f"Install failed: {e}", file=sys.stderr) print("Run 'smarttools install' to try again.", file=sys.stderr) return 0 def cmd_init(args): """Initialize a new smarttools.yaml.""" manifest_path = Path.cwd() / MANIFEST_FILENAME if manifest_path.exists() and not args.force: print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.") return 1 # Get project name default_name = Path.cwd().name if args.name: name = args.name else: try: name = input(f"Project name [{default_name}]: ").strip() or default_name except (EOFError, KeyboardInterrupt): print() name = default_name # Get version if args.version: version = args.version else: try: version = input("Version [1.0.0]: ").strip() or "1.0.0" except (EOFError, KeyboardInterrupt): print() version = "1.0.0" # Create manifest manifest = create_manifest(name=name, version=version) save_manifest(manifest, manifest_path) print(f"Created {MANIFEST_FILENAME}") print() print("Add dependencies with: smarttools add ") print("Install them with: smarttools install") return 0 def cmd_config(args): """Manage SmartTools configuration.""" if args.config_cmd == "show": config = load_config() print("SmartTools Configuration:") print(f" Registry URL: {config.registry.url}") print(f" Token: {'***' if config.registry.token else '(not set)'}") print(f" Client ID: {config.client_id}") print(f" Auto-fetch: {config.auto_fetch_from_registry}") if config.default_provider: print(f" Default provider: {config.default_provider}") return 0 elif args.config_cmd == "set-token": token = args.token set_registry_token(token) print("Registry token saved.") return 0 elif args.config_cmd == "set": config = load_config() key = args.key value = args.value if key == "auto_fetch": config.auto_fetch_from_registry = value.lower() in ("true", "1", "yes") elif key == "default_provider": config.default_provider = value if value else None elif key == "registry_url": config.registry.url = value else: print(f"Unknown config key: {key}", file=sys.stderr) print("Available keys: auto_fetch, default_provider, registry_url") return 1 save_config(config) print(f"Set {key} = {value}") return 0 else: print("Config commands:") print(" show Show current configuration") print(" set-token Set registry authentication token") print(" set Set a configuration value") 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))) # ------------------------------------------------------------------------- # Registry Commands # ------------------------------------------------------------------------- p_registry = subparsers.add_parser("registry", help="Registry commands (search, install, publish)") registry_sub = p_registry.add_subparsers(dest="registry_cmd", help="Registry commands") # registry search p_reg_search = registry_sub.add_parser("search", help="Search for tools") p_reg_search.add_argument("query", help="Search query") p_reg_search.add_argument("-c", "--category", help="Filter by category") p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)") p_reg_search.set_defaults(func=cmd_registry) # registry install p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry") p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)") p_reg_install.add_argument("-v", "--version", help="Version constraint") p_reg_install.set_defaults(func=cmd_registry) # registry uninstall p_reg_uninstall = registry_sub.add_parser("uninstall", help="Uninstall a tool") p_reg_uninstall.add_argument("tool", help="Tool to uninstall (owner/name)") p_reg_uninstall.set_defaults(func=cmd_registry) # registry info p_reg_info = registry_sub.add_parser("info", help="Show tool information") p_reg_info.add_argument("tool", help="Tool name (owner/name)") p_reg_info.set_defaults(func=cmd_registry) # registry update p_reg_update = registry_sub.add_parser("update", help="Update local index cache") p_reg_update.set_defaults(func=cmd_registry) # registry publish p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry") p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)") p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing") p_reg_publish.set_defaults(func=cmd_registry) # registry my-tools p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools") p_reg_mytools.set_defaults(func=cmd_registry) # registry browse p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)") p_reg_browse.set_defaults(func=cmd_registry) # Default for registry with no subcommand p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args))) # ------------------------------------------------------------------------- # Project Commands # ------------------------------------------------------------------------- # 'deps' command p_deps = subparsers.add_parser("deps", help="Show project dependencies") p_deps.set_defaults(func=cmd_deps) # 'install' command (for dependencies) p_install = subparsers.add_parser("install", help="Install dependencies from smarttools.yaml") p_install.set_defaults(func=cmd_install_deps) # 'add' command p_add = subparsers.add_parser("add", help="Add a tool to project dependencies") p_add.add_argument("tool", help="Tool to add (owner/name)") p_add.add_argument("-v", "--version", help="Version constraint (default: *)") p_add.add_argument("--no-install", action="store_true", help="Don't install after adding") p_add.set_defaults(func=cmd_add) # 'init' command p_init = subparsers.add_parser("init", help="Initialize smarttools.yaml") p_init.add_argument("-n", "--name", help="Project name") p_init.add_argument("-v", "--version", help="Project version") p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing") p_init.set_defaults(func=cmd_init) # ------------------------------------------------------------------------- # Config Commands # ------------------------------------------------------------------------- p_config = subparsers.add_parser("config", help="Manage configuration") config_sub = p_config.add_subparsers(dest="config_cmd", help="Config commands") # config show p_cfg_show = config_sub.add_parser("show", help="Show current configuration") p_cfg_show.set_defaults(func=cmd_config) # config set-token p_cfg_token = config_sub.add_parser("set-token", help="Set registry authentication token") p_cfg_token.add_argument("token", help="Registry token") p_cfg_token.set_defaults(func=cmd_config) # config set p_cfg_set = config_sub.add_parser("set", help="Set a configuration value") p_cfg_set.add_argument("key", help="Config key") p_cfg_set.add_argument("value", help="Config value") p_cfg_set.set_defaults(func=cmd_config) # Default for config with no subcommand p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(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())