Spaces:
Runtime error
Runtime error
| import sys | |
| from pathlib import Path | |
| from typing import Optional | |
| import typer | |
| from rich.console import Console | |
| from rich.table import Table | |
| from toolstore.config_manager import ConfigManager | |
| from toolstore.index_manager import IndexManager | |
| from toolstore.skill_manager import SkillDefinition, get_skill_manager | |
| from toolstore.toolset_manager import ToolsetDefinition, get_toolset_manager | |
| # Initialize Typer app and Rich console | |
| app = typer.Typer( | |
| name="toolstore", | |
| help="PyPI for AI Agents - Discover and Execute Tools", | |
| add_completion=False, | |
| ) | |
| console = Console() | |
| index_manager = IndexManager() | |
| config_manager = ConfigManager() | |
| index_manager.load() | |
| config_manager.load() | |
| def _base_url_from_registry(registry_url: str) -> str: | |
| """Strip the index path from the registry URL to get the API base. | |
| e.g. https://space.hf.space/index.json → https://space.hf.space | |
| http://localhost:8000/online_index → http://localhost:8000 | |
| """ | |
| for suffix in ("/online_index", "/index.json"): | |
| if registry_url.endswith(suffix): | |
| return registry_url[:-len(suffix)] | |
| return registry_url.rstrip("/") | |
| def main( | |
| ctx: typer.Context, | |
| version: bool = typer.Option(False, "--version", "-v", help="Show version") | |
| ): | |
| """ | |
| ToolStore CLI - The package manager for AI Agent tools. | |
| """ | |
| # Load index on startup | |
| index_manager.load() | |
| if version: | |
| from toolstore import __version__ | |
| console.print(f"ToolStore v{__version__}") | |
| raise typer.Exit() | |
| if ctx.invoked_subcommand is None: | |
| console.print(ctx.get_help()) | |
| raise typer.Exit() | |
| def update(): | |
| """ | |
| Download the latest public tool index and scan local MCP servers. | |
| """ | |
| console.print("[bold blue]Updating ToolStore index...[/bold blue]") | |
| # Download from registry | |
| import httpx | |
| registry_url = config_manager.get_registry_url() | |
| console.print(f"Fetching index from: {registry_url}") | |
| try: | |
| response = httpx.get(registry_url) | |
| response.raise_for_status() | |
| remote_tools = response.json() | |
| # Validate it's a list | |
| if not isinstance(remote_tools, list): | |
| # Fallback if wrapped in object (like server's /online_index return format might change) | |
| if isinstance(remote_tools, dict) and "tools" in remote_tools: | |
| # If it's a dict like {"tools": {...}} convert to list if needed, | |
| # but IndexManager.update_from_remote expects a list of tool dicts. | |
| # Let's check server/app/main.py: get_index returns List[dict]. | |
| pass | |
| if isinstance(remote_tools, list): | |
| index_manager.update_from_remote(remote_tools) | |
| console.print(f"[green]OK: Downloaded {len(remote_tools)} tools from registry[/green]") | |
| else: | |
| console.print(f"[red]Error:[/red] Registry returned unexpected format (expected list)") | |
| except Exception as e: | |
| console.print(f"[red]Failed to download index:[/red] {e}") | |
| console.print("Using cached index if available.") | |
| # Scan local MCP servers | |
| mcp_servers = config_manager.get_mcp_servers() | |
| mcp_tool_count = 0 | |
| for server_name, config in mcp_servers.items(): | |
| try: | |
| console.print(f"Scanning MCP server: [cyan]{server_name}[/cyan]...") | |
| from toolstore.mcp_client import FullMCPClient | |
| client = FullMCPClient(server_name, config) | |
| client.connect() | |
| tools = client.list_tools() | |
| # Format for index | |
| mcp_tools = [] | |
| for t in tools: | |
| t["type"] = "mcp" | |
| t["mcp_server"] = server_name | |
| t["source"] = f"mcp:{server_name}" | |
| mcp_tools.append(t) | |
| index_manager.update_from_remote(mcp_tools) | |
| mcp_tool_count += len(mcp_tools) | |
| client.disconnect() | |
| except Exception as e: | |
| console.print(f"[red]Failed to scan {server_name}:[/red] {e}") | |
| # Scan local toolsets | |
| toolset_dirs = config_manager.get_toolset_dirs() | |
| if toolset_dirs: | |
| try: | |
| tm = get_toolset_manager(toolset_dirs) | |
| tcount = tm.scan() | |
| if tcount: | |
| index_manager.update_from_remote(tm.get_all_tool_definitions()) | |
| except Exception as e: | |
| console.print(f"[red]Toolset scan failed:[/red] {e}") | |
| count = len(index_manager.index_data.get("tools", {})) | |
| console.print(f"OK: Index update complete ({count} total tools loaded)") | |
| def search(query: str): | |
| """ | |
| Search for tools by name, description, or tags. | |
| """ | |
| results = index_manager.search(query) | |
| if not results: | |
| console.print(f"No tools found for '[bold]{query}[/bold]'") | |
| return | |
| table = Table(title=f"Search Results: {query}") | |
| table.add_column("Name", style="cyan") | |
| table.add_column("Type", style="magenta") | |
| table.add_column("Description") | |
| for tool in results: | |
| table.add_row(tool["name"], tool["type"], tool.get("description", "")) | |
| console.print(table) | |
| def use( | |
| tool_name: str = typer.Argument(..., help="Name of the tool to execute"), | |
| params: list[str] = typer.Argument(None, help="Parameters in format key=value"), | |
| function: str = typer.Option(None, "--function", "-f", help="Function name to call (for toolset type)"), | |
| ): | |
| """ | |
| Execute a tool immediately. | |
| Examples: | |
| toolstore use weather-api latitude=37.77 longitude=-122.41 | |
| toolstore use my-toolset --function get_weather location=London | |
| """ | |
| tool = index_manager.get_tool(tool_name) | |
| if not tool: | |
| console.print(f"[red]Error:[/red] Tool '{tool_name}' not found.") | |
| raise typer.Exit(1) | |
| console.print(f"[bold green]Using tool:[/bold green] {tool_name}") | |
| # Parse params into dict (with basic type inference) | |
| parsed_params = {} | |
| if params: | |
| for p in params: | |
| if "=" in p: | |
| k, v = p.split("=", 1) | |
| # Basic type inference | |
| try: | |
| parsed_params[k] = int(v) | |
| except ValueError: | |
| try: | |
| parsed_params[k] = float(v) | |
| except ValueError: | |
| lower = v.lower() | |
| if lower == "true": | |
| parsed_params[k] = True | |
| elif lower == "false": | |
| parsed_params[k] = False | |
| elif lower == "null" or lower == "none": | |
| parsed_params[k] = None | |
| else: | |
| parsed_params[k] = v | |
| # Dispatch execution based on type | |
| tool_type = tool.get("type") | |
| if tool_type == "api": | |
| import httpx | |
| url = tool["endpoint"] | |
| method = tool.get("method", "GET").upper() | |
| # Handle path parameters if any (e.g. {area}/{location}) | |
| final_url = url | |
| for k, v in parsed_params.items(): | |
| if f"{{{k}}}" in final_url: | |
| final_url = final_url.replace(f"{{{k}}}", str(v)) | |
| console.print(f"Sending {method} request to: {final_url}") | |
| try: | |
| if method == "GET": | |
| query_params = {k: v for k, v in parsed_params.items() if f"{{{k}}}" not in url} | |
| response = httpx.get(final_url, params=query_params) | |
| else: | |
| response = httpx.post(final_url, json=parsed_params) | |
| console.print(f"\n[bold]Response ({response.status_code}):[/bold]") | |
| try: | |
| console.print(response.json()) | |
| except Exception: | |
| console.print(response.text) | |
| except Exception as e: | |
| console.print(f"[red]Execution failed:[/red] {e}") | |
| raise typer.Exit(1) | |
| elif tool_type == "mcp": | |
| server_name = tool.get("mcp_server") | |
| if not server_name: | |
| console.print("[red]Error:[/red] Tool definition missing 'mcp_server'") | |
| raise typer.Exit(1) | |
| servers = config_manager.get_mcp_servers() | |
| config = servers.get(server_name) | |
| if not config: | |
| console.print(f"[red]Error:[/red] MCP server '{server_name}' not found in config") | |
| raise typer.Exit(1) | |
| try: | |
| from toolstore.mcp_client import FullMCPClient | |
| client = FullMCPClient(server_name, config) | |
| client.connect() | |
| result = client.call_tool(tool["name"], parsed_params) | |
| client.disconnect() | |
| console.print("\n[bold]Result:[/bold]") | |
| console.print(result) | |
| except Exception as e: | |
| console.print(f"[red]MCP Execution failed:[/red] {e}") | |
| raise typer.Exit(1) | |
| elif tool_type == "docker": | |
| from toolstore.native_tool import _execute_docker | |
| console.print("[blue]Running docker-type tool...[/blue]") | |
| result = _execute_docker(tool, parsed_params) | |
| console.print(f"\n[bold]Output:[/bold]\n{result}") | |
| elif tool_type == "toolset": | |
| _use_toolset(tool, parsed_params, function) | |
| else: | |
| console.print(f"[yellow]Unknown tool type:[/yellow] {tool_type}") | |
| def info(tool_name: str): | |
| """ | |
| Show detailed information and schema for a tool. | |
| """ | |
| tool = index_manager.get_tool(tool_name) | |
| if not tool: | |
| console.print(f"[red]Error:[/red] Tool '{tool_name}' not found.") | |
| return | |
| console.print(f"[bold cyan]{tool['name']}[/bold cyan] ({tool.get('type')})") | |
| console.print(tool.get("description", "")) | |
| console.print("\n[bold]Schema:[/bold]") | |
| console.print(tool.get("schema")) | |
| def login( | |
| username: str = typer.Option(..., prompt=True), | |
| password: str = typer.Option(..., prompt=True, hide_input=True) | |
| ): | |
| """ | |
| Authenticate with the ToolStore registry to publish tools. | |
| """ | |
| import httpx | |
| # For V1 MVP, assume registry is at the base of the index URL | |
| # e.g. http://localhost:8000/index.json -> http://localhost:8000 | |
| base_url = _base_url_from_registry(config_manager.get_registry_url()) | |
| token_url = f"{base_url}/auth/token" | |
| console.print(f"Logging in to {base_url}...") | |
| try: | |
| response = httpx.post(token_url, data={ | |
| "username": username, | |
| "password": password | |
| }) | |
| if response.status_code == 200: | |
| token_data = response.json() | |
| access_token = token_data.get("access_token") | |
| if access_token: | |
| config_manager.save_token(access_token) | |
| console.print("[bold green]Login successful![/bold green]") | |
| else: | |
| console.print("[red]Login failed: No token received[/red]") | |
| else: | |
| console.print(f"[red]Login failed: {response.text}[/red]") | |
| except Exception as e: | |
| console.print(f"[red]Connection failed:[/red] {e}") | |
| def publish(tool_file: str = typer.Argument(..., help="Path to tool.json definition file")): | |
| """ | |
| Publish a new tool or update an existing one. | |
| """ | |
| import json | |
| import httpx | |
| from pathlib import Path | |
| # 1. Read Tool Definition | |
| path = Path(tool_file) | |
| if not path.exists(): | |
| console.print(f"[red]Error:[/red] File {tool_file} not found") | |
| raise typer.Exit(1) | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| tool_def = json.load(f) | |
| except Exception as e: | |
| console.print(f"[red]Invalid JSON:[/red] {e}") | |
| raise typer.Exit(1) | |
| # 2. Get Auth Token | |
| token = config_manager.get_token() | |
| if not token: | |
| console.print("[yellow]Please login first using 'toolstore login'[/yellow]") | |
| raise typer.Exit(1) | |
| # 3. Publish | |
| base_url = _base_url_from_registry(config_manager.get_registry_url()) | |
| publish_url = f"{base_url}/publish" | |
| console.print(f"Publishing [cyan]{tool_def.get('name')}[/cyan]...") | |
| try: | |
| headers = {"Authorization": f"Bearer {token}"} | |
| response = httpx.post(publish_url, json=tool_def, headers=headers) | |
| if response.status_code == 200: | |
| result = response.json() | |
| console.print(f"[bold green]Success![/bold green] Tool {result.get('action', 'published')}.") | |
| elif response.status_code == 401: | |
| console.print("[red]Unauthorized. Token may have expired. Please login again.[/red]") | |
| else: | |
| console.print(f"[red]Publish failed ({response.status_code}):[/red] {response.text}") | |
| except Exception as e: | |
| console.print(f"[red]Connection failed:[/red] {e}") | |
| def delete( | |
| tool_name: str = typer.Argument(..., help="Name of the tool to delete"), | |
| force: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation") | |
| ): | |
| """ | |
| Delete a tool from the registry. | |
| """ | |
| import httpx | |
| # 1. Get Auth Token | |
| token = config_manager.get_token() | |
| if not token: | |
| console.print("[yellow]Please login first using 'toolstore login'[/yellow]") | |
| raise typer.Exit(1) | |
| # 2. Delete | |
| base_url = _base_url_from_registry(config_manager.get_registry_url()) | |
| delete_url = f"{base_url}/tools/{tool_name}" | |
| # Confirm action | |
| if not force: | |
| confirm = typer.confirm(f"Are you sure you want to delete '{tool_name}'?") | |
| if not confirm: | |
| console.print("Aborted.") | |
| raise typer.Exit() | |
| console.print(f"Deleting [cyan]{tool_name}[/cyan]...") | |
| try: | |
| headers = {"Authorization": f"Bearer {token}"} | |
| response = httpx.delete(delete_url, headers=headers) | |
| if response.status_code == 200: | |
| console.print(f"[bold green]Success![/bold green] Tool deleted.") | |
| elif response.status_code == 404: | |
| console.print(f"[red]Tool '{tool_name}' not found.[/red]") | |
| elif response.status_code == 403: | |
| console.print(f"[red]Permission denied: You do not own this tool.[/red]") | |
| elif response.status_code == 401: | |
| console.print("[red]Unauthorized. Please login again.[/red]") | |
| else: | |
| console.print(f"[red]Delete failed ({response.status_code}):[/red] {response.text}") | |
| except Exception as e: | |
| console.print(f"[red]Connection failed:[/red] {e}") | |
| def export(): | |
| """ | |
| Export the ToolStore Meta-Tool schema for use with OpenAI/vLLM agents. | |
| """ | |
| import json | |
| schema = { | |
| "type": "function", | |
| "function": { | |
| "name": "tool_store", | |
| "description": "A universal tool manager that allows you to search for and execute thousands of public APIs and local utilities.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "action": { | |
| "type": "string", | |
| "enum": ["search", "execute", "info"], | |
| "description": "The action to perform: 'search' for tools, 'execute' to run a tool, or 'info' to get tool details." | |
| }, | |
| "query": { | |
| "type": "string", | |
| "description": "Search query (required for action='search')" | |
| }, | |
| "tool_name": { | |
| "type": "string", | |
| "description": "Name of the tool to execute or get info for (required for action='execute'/'info')" | |
| }, | |
| "arguments": { | |
| "type": "object", | |
| "description": "Arguments for the tool execution (required for action='execute')" | |
| } | |
| }, | |
| "required": ["action"] | |
| } | |
| } | |
| } | |
| console.print(json.dumps(schema, indent=2)) | |
| # ------------------------------------------------------------------ | |
| # Skill commands | |
| # ------------------------------------------------------------------ | |
| skill_app = typer.Typer(help="Manage Agent Skills (agentskills.io standard)") | |
| app.add_typer(skill_app, name="skill") | |
| def skill_scan( | |
| path: str = typer.Argument(None, help="Directory to scan for skills") | |
| ): | |
| """Scan for skills in configured directories (or a specific path).""" | |
| if path: | |
| config_manager.add_skill_dir(path) | |
| dirs = config_manager.get_skill_dirs() | |
| if not dirs: | |
| console.print("[yellow]No skill directories configured.[/yellow] " | |
| "Use 'toolstore skill add-dir <path>' first.") | |
| return | |
| sm = get_skill_manager(dirs) | |
| skills = sm.scan() | |
| if not skills: | |
| console.print("No skills found.") | |
| return | |
| # Register in index | |
| index_manager.update_from_remote(sm.to_tool_definitions()) | |
| console.print(f"[green]Found {len(skills)} skills:[/green]") | |
| for sd in skills: | |
| status = "[green]✓[/green]" if not sd.errors else "[red]✗[/red]" | |
| console.print(f" {status} {sd.name} — {sd.description[:80]}") | |
| for err in sd.errors: | |
| console.print(f" [red]! {err}[/red]") | |
| def skill_add_dir( | |
| path: str = typer.Argument(..., help="Directory path to add") | |
| ): | |
| """Add a directory to the skill search path.""" | |
| config_manager.add_skill_dir(path) | |
| console.print(f"[green]Added skill dir:[/green] {path}") | |
| def skill_remove_dir( | |
| path: str = typer.Argument(..., help="Directory to remove") | |
| ): | |
| """Remove a directory from the skill search path.""" | |
| config_manager.remove_skill_dir(path) | |
| console.print(f"[green]Removed skill dir:[/green] {path}") | |
| def skill_list_dirs(): | |
| """List configured skill directories.""" | |
| dirs = config_manager.get_skill_dirs() | |
| if not dirs: | |
| console.print("No skill directories configured.") | |
| return | |
| console.print("Skill directories:") | |
| for d in dirs: | |
| console.print(f" - {d}") | |
| def skill_show( | |
| name: str = typer.Argument(..., help="Skill name") | |
| ): | |
| """Display the full SKILL.md content of a skill.""" | |
| sm = get_skill_manager(config_manager.get_skill_dirs()) | |
| if not sm.get_skill(name): | |
| sm.scan() | |
| body = sm.get_skill_body(name) | |
| if body is None: | |
| console.print(f"[red]Skill '{name}' not found.[/red]") | |
| return | |
| console.print(f"[bold cyan]{name}[/bold cyan]\n") | |
| console.print(body) | |
| def skill_files( | |
| name: str = typer.Argument(..., help="Skill name") | |
| ): | |
| """List bundled files in a skill.""" | |
| sm = get_skill_manager(config_manager.get_skill_dirs()) | |
| if not sm.get_skill(name): | |
| sm.scan() | |
| sd = sm.get_skill(name) | |
| if not sd: | |
| console.print(f"[red]Skill '{name}' not found.[/red]") | |
| return | |
| flist = sd.list_files() | |
| if not flist: | |
| console.print("(no additional files bundled)") | |
| return | |
| console.print(f"Files in [bold]{name}[/bold]:") | |
| for f in flist: | |
| console.print(f" {f}") | |
| def skill_validate( | |
| path: str = typer.Argument(..., help="Path to skill directory") | |
| ): | |
| """Validate a SKILL.md file.""" | |
| from toolstore.skill_manager import SkillDefinition | |
| from pathlib import Path | |
| sd = SkillDefinition(Path(path)) | |
| if sd.load(): | |
| console.print(f"[green]✓[/green] {sd.name} is valid") | |
| console.print(f" Description: {sd.description[:100]}") | |
| else: | |
| console.print(f"[red]✗[/red] Validation failed for {path}") | |
| for err in sd.errors: | |
| console.print(f" [red]{err}[/red]") | |
| def skill_install( | |
| path: str = typer.Argument(..., help="Path to skill directory (containing SKILL.md)"), | |
| target: str = typer.Option(None, "--target", "-t", | |
| help="Target directory to install into (defaults to first configured skill dir)"), | |
| ): | |
| """Install a skill from a local directory into the ToolStore. | |
| Copies the skill into a configured skill directory, registers it, | |
| and rescans so it is immediately available to agents. | |
| Example: | |
| toolstore skill install ./my-skill | |
| toolstore skill install ~/skills/web-search --target /workspace/skills-uploaded | |
| """ | |
| sm = get_skill_manager(config_manager.get_skill_dirs()) | |
| sd = sm.install_skill(path, target) | |
| if sd is None: | |
| console.print(f"[red]Failed to install skill from {path}[/red]") | |
| console.print("Make sure the directory contains a valid SKILL.md file.") | |
| raise typer.Exit(1) | |
| # Persist skill dirs to config | |
| for d in sm.skill_dirs: | |
| config_manager.add_skill_dir(str(d)) | |
| # Update index | |
| index_manager.update_from_remote(sm.to_tool_definitions()) | |
| console.print(f"[green]✓ Installed skill:[/green] {sd.name}") | |
| console.print(f" Description: {sd.description[:100]}") | |
| files = sd.list_files() | |
| if files: | |
| console.print(f" Bundled files: {len(files)}") | |
| def skill_discover( | |
| path: str = typer.Argument(..., help="Root path to scan for skills"), | |
| shallow: bool = typer.Option(False, "--shallow", | |
| help="Single-level scan only (no recursion)"), | |
| json_out: bool = typer.Option(False, "--json", | |
| help="Output as JSON instead of tree"), | |
| ): | |
| """Discover skills in a folder tree. | |
| Walks the directory tree starting at PATH and finds all directories | |
| containing a SKILL.md file. Works for single-skill directories, | |
| flat collections, and nested / categorized folder structures | |
| (like skills-general/skills/). | |
| Examples: | |
| toolstore skill discover ./my-skill | |
| toolstore skill discover ./skills-general/skills | |
| toolstore skill discover ./skills-general/skills --shallow | |
| toolstore skill discover ./skills-general/skills --json | |
| """ | |
| import json as _json | |
| from toolstore.skill_discovery import discover_skills | |
| result = discover_skills(path, recursive=not shallow) | |
| if json_out: | |
| out = { | |
| "root": str(result.root_path), | |
| "total": result.total, | |
| "valid": result.valid_count, | |
| "invalid": result.invalid_count, | |
| "skills": [ | |
| { | |
| "name": ds.name, | |
| "description": ds.description, | |
| "category": ds.category or None, | |
| "rel_path": str(ds.rel_path), | |
| "valid": ds.is_valid, | |
| "errors": ds.errors if not ds.is_valid else [], | |
| } | |
| for ds in result.skills | |
| ], | |
| "scan_errors": result.scan_errors, | |
| } | |
| console.print(_json.dumps(out, indent=2)) | |
| return | |
| if result.total == 0: | |
| console.print(f"[yellow]No skills found in[/yellow] {result.root_path}") | |
| if result.scan_errors: | |
| for e in result.scan_errors: | |
| console.print(f" [red]{e}[/red]") | |
| return | |
| console.print(result.tree()) | |
| def _publish_one_skill( | |
| skill_dir: Path, registry_url: str, token: str, base_url: str | |
| ) -> tuple[bool, str]: | |
| """Publish a single skill to the registry. Returns (ok, message).""" | |
| import json | |
| import httpx | |
| sd = SkillDefinition(skill_dir) | |
| if not sd.load(): | |
| errors = "; ".join(sd.errors) | |
| return False, f"{skill_dir.name}: validation failed — {errors}" | |
| upload_data = sd.to_upload_dict() | |
| skills_publish_url = f"{base_url}/skills/publish" | |
| try: | |
| headers = { | |
| "Authorization": f"Bearer {token}", | |
| "Content-Type": "application/json", | |
| } | |
| response = httpx.post( | |
| skills_publish_url, json=upload_data, headers=headers | |
| ) | |
| if response.status_code == 200: | |
| result = response.json() | |
| action = result.get("action", "published") | |
| return True, f"{sd.name}: {action}" | |
| else: | |
| detail = "" | |
| try: | |
| detail = response.json().get("detail", response.text) | |
| except Exception: | |
| detail = response.text | |
| return False, f"{sd.name}: HTTP {response.status_code} — {detail}" | |
| except httpx.ConnectError: | |
| return False, f"{sd.name}: could not reach {base_url}" | |
| except Exception as exc: | |
| return False, f"{sd.name}: {exc}" | |
| def skill_publish( | |
| path: str = typer.Argument(..., help="Path to skill directory or folder of skills"), | |
| registry: str = typer.Option(None, "--registry", "-r", | |
| help="Registry URL (defaults to configured registry)"), | |
| batch: bool = typer.Option(False, "--batch", help="Publish all skills found in a folder tree"), | |
| yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt (batch mode)"), | |
| ): | |
| """Publish a skill (or batch of skills) to the ToolStore registry. | |
| Single mode (default): | |
| toolstore skill publish ./my-skill | |
| Batch mode (publish a whole tree): | |
| toolstore skill publish ./skills-general/skills --batch | |
| toolstore skill publish ./skills-general/skills --batch --yes | |
| """ | |
| import json | |
| import httpx | |
| from pathlib import Path | |
| # ------------------------------------------------------------------ | |
| # Authentication (shared by both modes) | |
| # ------------------------------------------------------------------ | |
| token = config_manager.get_token() | |
| if not token: | |
| console.print("[yellow]Please login first using 'toolstore login'[/yellow]") | |
| raise typer.Exit(1) | |
| base_url: str | |
| if registry: | |
| base_url = registry.rstrip("/") | |
| else: | |
| base_url = _base_url_from_registry(config_manager.get_registry_url()) | |
| # ------------------------------------------------------------------ | |
| # Batch mode | |
| # ------------------------------------------------------------------ | |
| if batch: | |
| from toolstore.skill_discovery import discover_skills | |
| result = discover_skills(path) | |
| if result.total == 0: | |
| console.print(f"[yellow]No skills found in {path}[/yellow]") | |
| if result.scan_errors: | |
| for e in result.scan_errors: | |
| console.print(f" [red]{e}[/red]") | |
| raise typer.Exit(1) | |
| console.print(result.tree()) | |
| if result.invalid_skills: | |
| console.print( | |
| f"\n[yellow]⚠ {result.invalid_count} skill(s) have validation " | |
| f"errors and will be skipped.[/yellow]" | |
| ) | |
| targets = result.valid_skills | |
| if not targets: | |
| console.print("[red]No valid skills to publish.[/red]") | |
| raise typer.Exit(1) | |
| # Confirmation | |
| if not yes: | |
| names = ", ".join(ds.name for ds in targets) | |
| console.print( | |
| f"\nAbout to publish [bold]{len(targets)} skill(s)[/bold]: " | |
| f"{names}" | |
| ) | |
| confirm = typer.confirm("Proceed?") | |
| if not confirm: | |
| console.print("Aborted.") | |
| raise typer.Exit(0) | |
| # Publish loop | |
| ok = 0 | |
| fail = 0 | |
| for ds in targets: | |
| ok_flag, msg = _publish_one_skill( | |
| ds.skill_def.skill_dir, registry, token, base_url | |
| ) | |
| if ok_flag: | |
| console.print(f" [green]✓[/green] {msg}") | |
| ok += 1 | |
| else: | |
| console.print(f" [red]✗[/red] {msg}") | |
| fail += 1 | |
| console.print( | |
| f"\n[bold]Done:[/bold] {ok} published, {fail} failed, " | |
| f"{result.invalid_count} skipped" | |
| ) | |
| if fail: | |
| raise typer.Exit(1) | |
| return | |
| # ------------------------------------------------------------------ | |
| # Single-skill mode (original behaviour) | |
| # ------------------------------------------------------------------ | |
| skill_dir = Path(path).resolve() | |
| if not skill_dir.is_dir(): | |
| console.print(f"[red]Error:[/red] '{path}' is not a directory") | |
| raise typer.Exit(1) | |
| sd = SkillDefinition(skill_dir) | |
| if not sd.load(): | |
| console.print(f"[red]✗ Skill validation failed:[/red]") | |
| for err in sd.errors: | |
| console.print(f" [red]• {err}[/red]") | |
| raise typer.Exit(1) | |
| console.print(f"[green]✓[/green] Validated: {sd.name}") | |
| console.print(f" Description: {sd.description[:100]}") | |
| files_count = len(sd.list_files()) | |
| if files_count: | |
| console.print(f" Bundled files: {files_count}") | |
| upload_data = sd.to_upload_dict() | |
| console.print( | |
| f"[blue]Preparing upload " | |
| f"({len(upload_data.get('body', ''))} bytes body)...[/blue]" | |
| ) | |
| skills_publish_url = f"{base_url}/skills/publish" | |
| console.print(f"Publishing [cyan]{sd.name}[/cyan] to {base_url}...") | |
| try: | |
| headers = { | |
| "Authorization": f"Bearer {token}", | |
| "Content-Type": "application/json", | |
| } | |
| response = httpx.post( | |
| skills_publish_url, json=upload_data, headers=headers | |
| ) | |
| if response.status_code == 200: | |
| result = response.json() | |
| action = result.get("action", "published") | |
| console.print( | |
| f"[bold green]✓ Skill {action}![/bold green] " | |
| f"Now available at {base_url}/skills/{sd.name}" | |
| ) | |
| elif response.status_code == 401: | |
| console.print( | |
| "[red]Unauthorized. Token may have expired. " | |
| "Please login again.[/red]" | |
| ) | |
| raise typer.Exit(1) | |
| elif response.status_code == 403: | |
| console.print( | |
| "[red]Permission denied: You do not own this skill " | |
| "on the server.[/red]" | |
| ) | |
| raise typer.Exit(1) | |
| else: | |
| detail = "" | |
| try: | |
| detail = response.json().get("detail", response.text) | |
| except Exception: | |
| detail = response.text | |
| console.print( | |
| f"[red]Publish failed ({response.status_code}):[/red] {detail}" | |
| ) | |
| raise typer.Exit(1) | |
| except httpx.ConnectError: | |
| console.print( | |
| f"[red]Connection failed:[/red] Could not reach {base_url}" | |
| ) | |
| raise typer.Exit(1) | |
| except Exception as e: | |
| console.print(f"[red]Error:[/red] {e}") | |
| raise typer.Exit(1) | |
| # ------------------------------------------------------------------ | |
| # Toolset commands | |
| # ------------------------------------------------------------------ | |
| toolset_app = typer.Typer(help="Manage Toolsets (agent-centric managed tools)") | |
| app.add_typer(toolset_app, name="toolset") | |
| def toolset_scan( | |
| path: str = typer.Argument(None, help="Directory to scan for toolsets") | |
| ): | |
| """Scan for toolsets in configured directories (or a specific path).""" | |
| if path: | |
| config_manager.add_toolset_dir(path) | |
| dirs = config_manager.get_toolset_dirs() | |
| if not dirs: | |
| console.print("[yellow]No toolset directories configured.[/yellow] " | |
| "Use 'toolstore toolset add-dir <path>' first.") | |
| return | |
| tm = get_toolset_manager(dirs) | |
| count = tm.scan() | |
| if count == 0: | |
| console.print("No toolsets found.") | |
| return | |
| # Register in index | |
| index_manager.update_from_remote(tm.get_all_tool_definitions()) | |
| toolsets = tm.get_all() | |
| console.print(f"[green]Found {len(toolsets)} toolsets:[/green]") | |
| for td in toolsets: | |
| status = "[green]✓[/green]" if td.is_valid else "[red]✗[/red]" | |
| fn_count = len(td.functions) | |
| console.print(f" {status} {td.name} ({fn_count} function(s))") | |
| if td.doc: | |
| first_line = td.doc.split("\n")[0][:80] | |
| console.print(f" {first_line}") | |
| for err in td.errors: | |
| console.print(f" [red]! {err}[/red]") | |
| def toolset_add_dir( | |
| path: str = typer.Argument(..., help="Directory path to add") | |
| ): | |
| """Add a directory to the toolset search path.""" | |
| config_manager.add_toolset_dir(path) | |
| console.print(f"[green]Added toolset dir:[/green] {path}") | |
| def toolset_remove_dir( | |
| path: str = typer.Argument(..., help="Directory to remove") | |
| ): | |
| """Remove a directory from the toolset search path.""" | |
| config_manager.remove_toolset_dir(path) | |
| console.print(f"[green]Removed toolset dir:[/green] {path}") | |
| def toolset_list_dirs(): | |
| """List configured toolset directories.""" | |
| dirs = config_manager.get_toolset_dirs() | |
| if not dirs: | |
| console.print("No toolset directories configured.") | |
| return | |
| console.print("Toolset directories:") | |
| for d in dirs: | |
| console.print(f" - {d}") | |
| def toolset_show( | |
| name: str = typer.Argument(..., help="Toolset name") | |
| ): | |
| """Display the full doc.md content of a toolset.""" | |
| dirs = config_manager.get_toolset_dirs() | |
| tm = get_toolset_manager(dirs) | |
| if not tm.get(name): | |
| tm.scan() | |
| td = tm.get(name) | |
| if td is None: | |
| console.print(f"[red]Toolset '{name}' not found.[/red]") | |
| return | |
| console.print(f"[bold cyan]{td.name}[/bold cyan]\n") | |
| if td.doc: | |
| console.print(td.doc) | |
| else: | |
| console.print("[dim](no doc.md found)[/dim]") | |
| console.print(f"\n[bold]Functions ({len(td.functions)}):[/bold]") | |
| for fn_name, fn_info in td.functions.items(): | |
| params = fn_info.get("parameters", {}) | |
| param_strs = [] | |
| for pname, pinfo in params.items(): | |
| req = "" if not pinfo.get("required") else "" | |
| param_strs.append(f"{pname}") | |
| sig = f"{fn_name}({', '.join(param_strs)})" | |
| console.print(f" [cyan]{sig}[/cyan]") | |
| if fn_info.get("description"): | |
| console.print(f" {fn_info['description']}") | |
| def toolset_validate( | |
| path: str = typer.Argument(..., help="Path to toolset directory") | |
| ): | |
| """Validate a toolset directory.""" | |
| from pathlib import Path | |
| td = ToolsetDefinition(Path(path)) | |
| if td.load(): | |
| console.print(f"[green]✓[/green] {td.name} is valid") | |
| console.print(f" Functions: {list(td.functions.keys())}") | |
| if td.doc: | |
| first_line = td.doc.split("\n")[0][:100] | |
| console.print(f" Doc: {first_line}") | |
| else: | |
| console.print(f"[red]✗[/red] Validation failed for {path}") | |
| for err in td.errors: | |
| console.print(f" [red]{err}[/red]") | |
| def toolset_list(): | |
| """List all discovered toolsets.""" | |
| dirs = config_manager.get_toolset_dirs() | |
| tm = get_toolset_manager(dirs) | |
| if not tm.get_all(): | |
| tm.scan() | |
| toolsets = tm.get_all() | |
| if not toolsets: | |
| console.print("No toolsets found.") | |
| console.print("Use 'toolstore toolset add-dir <path>' then 'toolstore toolset scan'.") | |
| return | |
| table = Table(title="Discovered Toolsets") | |
| table.add_column("Name", style="cyan") | |
| table.add_column("Functions", style="green") | |
| table.add_column("Description") | |
| for td in toolsets: | |
| fn_count = str(len(td.functions)) | |
| desc = (td.doc.split("\n")[0] if td.doc else "(no doc)")[:80] | |
| table.add_row(td.name, fn_count, desc) | |
| console.print(table) | |
| def toolset_publish( | |
| path: str = typer.Argument(..., help="Path to toolset directory"), | |
| ): | |
| """Publish a toolset to the ToolStore registry.""" | |
| import json | |
| import httpx | |
| from pathlib import Path | |
| toolset_dir = Path(path).resolve() | |
| if not toolset_dir.is_dir(): | |
| console.print(f"[red]Error:[/red] '{path}' is not a directory") | |
| raise typer.Exit(1) | |
| td = ToolsetDefinition(toolset_dir) | |
| if not td.load(): | |
| console.print(f"[red]✗ Toolset validation failed:[/red]") | |
| for err in td.errors: | |
| console.print(f" [red]• {err}[/red]") | |
| raise typer.Exit(1) | |
| console.print(f"[green]✓[/green] Validated: {td.name}") | |
| console.print(f" Functions: {list(td.functions.keys())}") | |
| # Read toolset.py source | |
| code_path = toolset_dir / "toolset.py" | |
| code = code_path.read_text(encoding="utf-8") | |
| # Build upload payload | |
| upload_data = { | |
| "name": td.name, | |
| "type": "toolset", | |
| "description": td.doc.split("\n")[0] if td.doc else td.name, | |
| "doc": td.doc, | |
| "code": code, | |
| "bindings": td.functions, | |
| } | |
| # Auth | |
| token = config_manager.get_token() | |
| if not token: | |
| console.print("[yellow]Please login first using 'toolstore login'[/yellow]") | |
| raise typer.Exit(1) | |
| base_url = _base_url_from_registry(config_manager.get_registry_url()) | |
| publish_url = f"{base_url}/publish" | |
| console.print(f"Publishing [cyan]{td.name}[/cyan] to {base_url}...") | |
| try: | |
| headers = {"Authorization": f"Bearer {token}"} | |
| response = httpx.post(publish_url, json=upload_data, headers=headers) | |
| if response.status_code == 200: | |
| result = response.json() | |
| action = result.get("action", "published") | |
| console.print(f"[bold green]✓ Toolset {action}![/bold green]") | |
| elif response.status_code == 401: | |
| console.print("[red]Unauthorized. Please login again.[/red]") | |
| raise typer.Exit(1) | |
| else: | |
| detail = "" | |
| try: | |
| detail = response.json().get("detail", response.text) | |
| except Exception: | |
| detail = response.text | |
| console.print(f"[red]Publish failed ({response.status_code}):[/red] {detail}") | |
| raise typer.Exit(1) | |
| except httpx.ConnectError: | |
| console.print(f"[red]Connection failed:[/red] Could not reach {base_url}") | |
| raise typer.Exit(1) | |
| except Exception as e: | |
| console.print(f"[red]Error:[/red] {e}") | |
| raise typer.Exit(1) | |
| # ------------------------------------------------------------------ | |
| # Helper: toolset execution from CLI | |
| # ------------------------------------------------------------------ | |
| def _use_toolset(tool: dict, parsed_params: dict, function: str | None) -> None: | |
| """Execute a toolset (local or remote) from the CLI.""" | |
| bindings = tool.get("bindings", {}) | |
| # Determine which function to call | |
| if function: | |
| fn_name = function | |
| elif "function" in parsed_params: | |
| fn_name = parsed_params.pop("function") | |
| elif len(bindings) == 1: | |
| fn_name = next(iter(bindings)) | |
| else: | |
| names = list(bindings.keys()) if bindings else [] | |
| console.print( | |
| f"[red]Error:[/red] Multiple functions available. " | |
| f"Use --function to specify one. " | |
| f"Available: {', '.join(names) or '(none)'}" | |
| ) | |
| raise typer.Exit(1) | |
| # Validate the function exists | |
| if fn_name not in bindings: | |
| names = list(bindings.keys()) | |
| console.print( | |
| f"[red]Error:[/red] Unknown function '{fn_name}'. " | |
| f"Available: {', '.join(names)}" | |
| ) | |
| raise typer.Exit(1) | |
| # Dispatch: local vs remote | |
| toolset_dir = tool.get("toolset_dir") | |
| code = tool.get("code") or tool.get("code_base64") | |
| if toolset_dir: | |
| console.print(f"[blue]Running local toolset:[/blue] {tool['name']}.{fn_name}") | |
| console.print(f" Source: {toolset_dir}") | |
| from toolstore.native_tool import _execute_toolset_local | |
| result = _execute_toolset_local(toolset_dir, fn_name, parsed_params) | |
| console.print(f"\n[bold]Result:[/bold]\n{result}") | |
| elif code: | |
| console.print(f"[blue]Running remote toolset:[/blue] {tool['name']}.{fn_name}") | |
| docker_image = tool.get("docker_image", "unknown") | |
| console.print(f" Image: {docker_image}") | |
| from toolstore.exec_tools import _execute_toolset_remote | |
| result = _execute_toolset_remote(tool, fn_name, parsed_params) | |
| console.print(f"\n[bold]Result:[/bold]\n{result}") | |
| else: | |
| console.print("[red]Error:[/red] Toolset has neither 'toolset_dir' nor 'code' — cannot execute") | |
| raise typer.Exit(1) | |
| # ------------------------------------------------------------------ | |
| # Serve command (ToolStore as MCP server) | |
| # ------------------------------------------------------------------ | |
| def serve( | |
| mode: str = typer.Option("stdio", "--mode", "-m", | |
| help="Transport mode: stdio or sse"), | |
| port: int = typer.Option(9090, "--port", "-p", help="SSE port"), | |
| host: str = typer.Option("127.0.0.1", "--host", help="SSE host"), | |
| ): | |
| """ | |
| Run ToolStore as an MCP server. | |
| Clients (e.g. Claude Desktop, VS Code) can connect and use all | |
| indexed tools through the standard MCP protocol. | |
| """ | |
| from toolstore.mcp_server import ToolStoreMCPServer | |
| # Ensure skills are loaded | |
| sm = get_skill_manager(config_manager.get_skill_dirs()) | |
| if config_manager.get_skill_dirs(): | |
| console.print(f"[blue]Scanning skills...[/blue]") | |
| skills = sm.scan() | |
| if skills: | |
| index_manager.update_from_remote(sm.to_tool_definitions()) | |
| console.print(f"[green]Loaded {len(skills)} skills[/green]") | |
| # Ensure toolsets are loaded | |
| tm = get_toolset_manager(config_manager.get_toolset_dirs()) | |
| if config_manager.get_toolset_dirs(): | |
| console.print(f"[blue]Scanning toolsets...[/blue]") | |
| tcount = tm.scan() | |
| if tcount: | |
| index_manager.update_from_remote(tm.get_all_tool_definitions()) | |
| console.print(f"[green]Loaded {tcount} toolsets[/green]") | |
| server = ToolStoreMCPServer(index_manager, config_manager, sm) | |
| if mode == "stdio": | |
| console.print(f"[bold green]ToolStore MCP Server v2.0.0[/bold green] " | |
| f"— listening on stdio") | |
| server.run_stdio() | |
| elif mode == "sse": | |
| # Start SSE server via FastAPI | |
| try: | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import StreamingResponse | |
| import uvicorn | |
| import asyncio | |
| import json as _json | |
| except ImportError: | |
| console.print("[red]SSE mode requires FastAPI + uvicorn: " | |
| "pip install fastapi uvicorn[/red]") | |
| raise typer.Exit(1) | |
| app_fast = FastAPI(title="ToolStore MCP Server") | |
| sse_queues: list = [] | |
| async def sse_endpoint(request: Request): | |
| async def event_stream(): | |
| q: asyncio.Queue = asyncio.Queue() | |
| sse_queues.append(q) | |
| try: | |
| yield f"event: endpoint\ndata: /message\n\n" | |
| while True: | |
| if await request.is_disconnected(): | |
| break | |
| try: | |
| msg = await asyncio.wait_for(q.get(), timeout=15) | |
| yield f"data: {_json.dumps(msg)}\n\n" | |
| except asyncio.TimeoutError: | |
| yield ": keepalive\n\n" | |
| finally: | |
| sse_queues.remove(q) | |
| return StreamingResponse(event_stream(), | |
| media_type="text/event-stream") | |
| async def message_endpoint(request: Request): | |
| body = await request.json() | |
| # Process via server | |
| resp_container: list = [] | |
| def collect(msg): | |
| resp_container.append(msg) | |
| server.set_send_callback(collect) | |
| server.handle_message(body) | |
| # Broadcast response to all SSE clients | |
| for msg in resp_container: | |
| for q in sse_queues: | |
| await q.put(msg) | |
| return {"status": "accepted"} | |
| console.print(f"[bold green]ToolStore MCP Server v2.0.0[/bold green] " | |
| f"— SSE on {host}:{port}") | |
| uvicorn.run(app_fast, host=host, port=port) | |
| else: | |
| console.print(f"[red]Unknown mode: {mode}[/red]. Use stdio or sse.") | |
| # ------------------------------------------------------------------ | |
| # MCP-server commands (register / manage MCP servers) | |
| # ------------------------------------------------------------------ | |
| mcp_server_app = typer.Typer(help="Register and manage MCP servers") | |
| app.add_typer(mcp_server_app, name="mcp-server") | |
| def mcp_server_add_docker( | |
| name: str = typer.Argument(..., help="Name for this MCP server"), | |
| image: str = typer.Argument(..., help="Docker image (e.g. ghcr.io/user/weather-mcp:v1)"), | |
| entrypoint: str = typer.Option( | |
| "python -m server", "--entrypoint", "-e", | |
| help="Entrypoint command inside the container (space-separated)", | |
| ), | |
| ): | |
| """Register a Docker-based MCP server. | |
| The container will be started on-demand and kept alive across tool calls. | |
| JSON-RPC travels over stdin/stdout — the same persistent model used by | |
| docker-type tools. | |
| Example: toolstore mcp-server add-docker weather ghcr.io/acme/weather-mcp:v1 | |
| """ | |
| # Validate docker is available (warning, not fatal — can be installed later) | |
| from toolstore.docker_pool import check_docker_available | |
| docker_err = check_docker_available() | |
| if docker_err: | |
| console.print(f"[yellow]Warning:[/yellow] {docker_err}") | |
| console.print("The server is registered but won't work until Docker is available.") | |
| # Check approval if configured | |
| mode = config_manager.get_docker_approval_mode() | |
| if mode == "list": | |
| approved = config_manager.get_approved_docker_images() | |
| if image not in approved: | |
| console.print( | |
| f"[yellow]Warning:[/yellow] Image '{image}' is not in your approved list.\n" | |
| f"Use 'toolstore docker approve {image}' to add it, " | |
| f"or 'toolstore docker mode all' to allow any image." | |
| ) | |
| elif mode == "none": | |
| console.print( | |
| f"[yellow]Warning:[/yellow] Custom Docker images are blocked " | |
| f"(approval mode is 'none').\n" | |
| f"Use 'toolstore docker mode list' or 'toolstore docker mode all' to allow." | |
| ) | |
| entrypoint_parts = entrypoint.split() | |
| config_manager.add_mcp_docker_server(name, image, entrypoint_parts) | |
| console.print( | |
| f"[green]Registered Docker MCP server '{name}'[/green]\n" | |
| f" Image: [bold]{image}[/bold]\n" | |
| f" Entrypoint: [bold]{' '.join(entrypoint_parts)}[/bold]\n" | |
| f"\nRun 'toolstore update' to discover its tools." | |
| ) | |
| def mcp_server_add( | |
| name: str = typer.Argument(..., help="Name for this MCP server"), | |
| command: str = typer.Argument(..., help="Command to start the server (e.g. npx)"), | |
| args: str = typer.Option(None, "--args", "-a", help="Additional arguments (space-separated)"), | |
| ): | |
| """Register a stdio-based MCP server (local process, not Docker).""" | |
| server_config: dict = {"command": command} | |
| if args: | |
| server_config["args"] = args.split() | |
| config_manager.set_mcp_server(name, server_config) | |
| console.print( | |
| f"[green]Registered MCP server '{name}'[/green]\n" | |
| f" Command: [bold]{command} {' '.join(server_config.get('args', []))}[/bold]\n" | |
| f"\nRun 'toolstore update' to discover its tools." | |
| ) | |
| def mcp_server_remove( | |
| name: str = typer.Argument(..., help="MCP server name to remove"), | |
| ): | |
| """Remove a registered MCP server.""" | |
| servers = config_manager.get_mcp_servers() | |
| if name not in servers: | |
| console.print(f"[red]MCP server '{name}' not found.[/red]") | |
| raise typer.Exit(1) | |
| config_manager.remove_mcp_server(name) | |
| # Note: MCP server containers are managed by DockerTransport, | |
| # not the shared worker — they stop when the client disconnects. | |
| console.print(f"[green]Removed MCP server '{name}'.[/green]") | |
| def mcp_server_list(): | |
| """List all registered MCP servers.""" | |
| servers = config_manager.get_mcp_servers() | |
| if not servers: | |
| console.print("No MCP servers registered.") | |
| console.print( | |
| "Use 'toolstore mcp-server add <name> <command> [args]' " | |
| "or 'toolstore mcp-server add-docker <name> <image>' to add one." | |
| ) | |
| return | |
| console.print("[bold]Registered MCP servers:[/bold]\n") | |
| for sname, cfg in servers.items(): | |
| transport = cfg.get("type") or ("docker" if "image" in cfg else "stdio") | |
| if transport == "docker": | |
| console.print( | |
| f" [cyan]{sname}[/cyan] (docker)\n" | |
| f" image: {cfg['image']}\n" | |
| f" entrypoint: {' '.join(cfg.get('entrypoint', ['python', '-m', 'server']))}" | |
| ) | |
| else: | |
| cmd = cfg.get("command", "?") | |
| args_str = ' '.join(cfg.get("args", [])) | |
| console.print(f" [cyan]{sname}[/cyan] (stdio)\n {cmd} {args_str}") | |
| console.print(f"\nRun 'toolstore update' to scan for tools.") | |
| # Note: search/install/publish commands have been removed. | |
| # MCP server manifests are no longer stored on the ToolStore registry. | |
| # To find MCP servers, visit the official MCP registry or use: | |
| # toolstore mcp-server add <name> <command> (for stdio servers) | |
| # toolstore mcp-server add-docker <name> <image> (for Docker servers) | |
| # ------------------------------------------------------------------ | |
| # Docker approval commands (client-side permission control) | |
| # ------------------------------------------------------------------ | |
| docker_app = typer.Typer(help="Manage Docker execution permissions and defaults") | |
| app.add_typer(docker_app, name="docker") | |
| def docker_mode( | |
| mode: str = typer.Argument(None, help="Approval mode: none, list, or all"), | |
| ): | |
| """Get or set the Docker-approval mode. | |
| none — No custom Docker images allowed (only the default base image). | |
| list — Only images in the approved list are allowed. | |
| all — Any Docker image is allowed (no restrictions). | |
| Run without a value to show the current mode. | |
| """ | |
| if mode is None: | |
| current = config_manager.get_docker_approval_mode() | |
| console.print(f"Docker approval mode: [bold]{current}[/bold]") | |
| approved = config_manager.get_approved_docker_images() | |
| if current == "list" and approved: | |
| console.print("Approved images:") | |
| for img in approved: | |
| console.print(f" - {img}") | |
| return | |
| try: | |
| config_manager.set_docker_approval_mode(mode.lower()) | |
| console.print(f"[green]Docker approval mode set to '{mode.lower()}'.[/green]") | |
| except ValueError as exc: | |
| console.print(f"[red]{exc}[/red]") | |
| raise typer.Exit(1) | |
| def docker_approve( | |
| image: str = typer.Argument(..., help="Docker image to approve (e.g. python:3.11)"), | |
| ): | |
| """Add a Docker image to the approved list.""" | |
| config_manager.add_approved_docker_image(image) | |
| console.print(f"[green]Added '{image}' to approved Docker images.[/green]") | |
| console.print( | |
| "[yellow]Note:[/yellow] approval mode is currently " | |
| f"'{config_manager.get_docker_approval_mode()}'." | |
| ) | |
| def docker_revoke( | |
| image: str = typer.Argument(..., help="Docker image to remove from the approved list"), | |
| ): | |
| """Remove a Docker image from the approved list.""" | |
| config_manager.remove_approved_docker_image(image) | |
| console.print(f"[green]Removed '{image}' from approved Docker images.[/green]") | |
| def docker_list(): | |
| """List all approved Docker images and the current approval mode.""" | |
| mode = config_manager.get_docker_approval_mode() | |
| console.print(f"Approval mode: [bold cyan]{mode}[/bold cyan]") | |
| approved = config_manager.get_approved_docker_images() | |
| if mode == "all": | |
| console.print("All Docker images are allowed.") | |
| elif not approved: | |
| console.print("No Docker images have been approved yet.") | |
| console.print( | |
| "Use 'toolstore docker approve <image>' to add one, " | |
| "or 'toolstore docker mode all' to allow any image." | |
| ) | |
| else: | |
| console.print("Approved images:") | |
| for img in approved: | |
| console.print(f" - {img}") | |
| console.print(f"\nDefault image: [bold]{config_manager.get_default_docker_image()}[/bold]") | |
| def docker_default_image( | |
| image: str = typer.Argument(None, help="New default Docker image (e.g. python:3.12-slim)"), | |
| ): | |
| """Get or set the default Docker image for docker-type tools.""" | |
| if image is None: | |
| console.print( | |
| f"Default Docker image: " | |
| f"[bold]{config_manager.get_default_docker_image()}[/bold]" | |
| ) | |
| return | |
| config_manager.set_default_docker_image(image) | |
| console.print(f"[green]Default Docker image set to '{image}'.[/green]") | |
| if __name__ == "__main__": | |
| app() |