cade-cli 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. cade_cli-0.3.3.dist-info/METADATA +151 -0
  2. cade_cli-0.3.3.dist-info/RECORD +44 -0
  3. cade_cli-0.3.3.dist-info/WHEEL +4 -0
  4. cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
  5. cadecoder/__init__.py +1 -0
  6. cadecoder/ai/__init__.py +6 -0
  7. cadecoder/ai/prompts.py +572 -0
  8. cadecoder/cli/__init__.py +0 -0
  9. cadecoder/cli/app.py +147 -0
  10. cadecoder/cli/auth.py +483 -0
  11. cadecoder/cli/commands/__init__.py +5 -0
  12. cadecoder/cli/commands/auth.py +143 -0
  13. cadecoder/cli/commands/chat.py +264 -0
  14. cadecoder/cli/commands/mcp.py +477 -0
  15. cadecoder/cli/commands/tools.py +226 -0
  16. cadecoder/core/__init__.py +12 -0
  17. cadecoder/core/config.py +380 -0
  18. cadecoder/core/constants.py +281 -0
  19. cadecoder/core/errors.py +145 -0
  20. cadecoder/core/logging.py +148 -0
  21. cadecoder/core/types.py +235 -0
  22. cadecoder/core/utils.py +279 -0
  23. cadecoder/execution/__init__.py +46 -0
  24. cadecoder/execution/context_window.py +521 -0
  25. cadecoder/execution/orchestrator.py +562 -0
  26. cadecoder/execution/parallel.py +287 -0
  27. cadecoder/providers/__init__.py +60 -0
  28. cadecoder/providers/base.py +294 -0
  29. cadecoder/providers/openai.py +251 -0
  30. cadecoder/storage/__init__.py +0 -0
  31. cadecoder/storage/threads.py +489 -0
  32. cadecoder/templates/login_failed.html +21 -0
  33. cadecoder/templates/login_success.html +21 -0
  34. cadecoder/templates/styles.css +87 -0
  35. cadecoder/tools/__init__.py +19 -0
  36. cadecoder/tools/builtin.py +644 -0
  37. cadecoder/tools/filesystem.py +315 -0
  38. cadecoder/tools/git.py +221 -0
  39. cadecoder/tools/manager.py +1635 -0
  40. cadecoder/ui/__init__.py +7 -0
  41. cadecoder/ui/display.py +338 -0
  42. cadecoder/ui/input.py +145 -0
  43. cadecoder/ui/session.py +455 -0
  44. cadecoder/ui/state.py +20 -0
@@ -0,0 +1,477 @@
1
+ """CLI commands for managing MCP servers."""
2
+
3
+ import asyncio
4
+ import http.server
5
+ import threading
6
+ import webbrowser
7
+ from typing import Annotated
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from cadecoder.tools.manager import (
15
+ MCPAuthType,
16
+ MCPServerConfig,
17
+ MCPServerStore,
18
+ MCPToolManager,
19
+ )
20
+
21
+ # Create MCP command group
22
+ mcp_app = typer.Typer(
23
+ name="mcp",
24
+ help="Manage MCP (Model Context Protocol) servers",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ @mcp_app.command("list")
32
+ def list_servers() -> None:
33
+ """List all configured MCP servers."""
34
+ store = MCPServerStore()
35
+ servers = store.list_all()
36
+
37
+ if not servers:
38
+ console.print("[yellow]No MCP servers configured.[/yellow]")
39
+ console.print("[dim]Use 'cade mcp add <name> <url>' to add a server.[/dim]")
40
+ return
41
+
42
+ table = Table(title="MCP Servers")
43
+ table.add_column("Name", style="cyan")
44
+ table.add_column("URL", style="white")
45
+ table.add_column("Auth", style="yellow")
46
+ table.add_column("Enabled", style="green")
47
+ table.add_column("Tools", style="blue")
48
+ table.add_column("Last Connected", style="dim")
49
+
50
+ for server in servers:
51
+ enabled = "✓" if server.enabled else "✗"
52
+ last_conn = (
53
+ server.last_connected.strftime("%Y-%m-%d %H:%M") if server.last_connected else "-"
54
+ )
55
+ auth_display = server.auth_type.value
56
+ if server.auth_type != MCPAuthType.NONE:
57
+ auth_display += " ✓" if server.auth_value else " ✗"
58
+
59
+ table.add_row(
60
+ server.name,
61
+ server.url,
62
+ auth_display,
63
+ enabled,
64
+ str(server.tool_count) if server.tool_count else "-",
65
+ last_conn,
66
+ )
67
+
68
+ console.print(table)
69
+
70
+
71
+ @mcp_app.command("add")
72
+ def add_server(
73
+ name: Annotated[str, typer.Argument(help="Unique name for the server")],
74
+ url: Annotated[str, typer.Argument(help="Server URL (e.g., http://localhost:8080)")],
75
+ auth_type: Annotated[
76
+ str,
77
+ typer.Option(
78
+ "--auth",
79
+ "-a",
80
+ help="Authentication type: none, bearer, or api_key",
81
+ ),
82
+ ] = "none",
83
+ auth_value: Annotated[
84
+ str | None,
85
+ typer.Option(
86
+ "--token",
87
+ "-t",
88
+ help="Authentication token or API key",
89
+ ),
90
+ ] = None,
91
+ disabled: Annotated[
92
+ bool,
93
+ typer.Option(
94
+ "--disabled",
95
+ "-d",
96
+ help="Add server in disabled state",
97
+ ),
98
+ ] = False,
99
+ ) -> None:
100
+ """Add a new MCP server configuration."""
101
+ store = MCPServerStore()
102
+
103
+ # Check if server already exists
104
+ existing = store.get(name)
105
+ if existing:
106
+ console.print(f"[red]Server '{name}' already exists.[/red]")
107
+ console.print("[dim]Use 'cade mcp rm' first or choose a different name.[/dim]")
108
+ raise typer.Exit(1)
109
+
110
+ # Validate auth type
111
+ try:
112
+ auth_enum = MCPAuthType(auth_type.lower())
113
+ except ValueError:
114
+ console.print(f"[red]Invalid auth type: {auth_type}[/red]")
115
+ console.print("[dim]Valid types: none, bearer, api_key[/dim]")
116
+ raise typer.Exit(1)
117
+
118
+ # Warn if auth requires value
119
+ if auth_enum != MCPAuthType.NONE and not auth_value:
120
+ console.print(
121
+ f"[yellow]Warning: Auth type '{auth_type}' specified but no token provided.[/yellow]"
122
+ )
123
+
124
+ # Create and save config
125
+ config = MCPServerConfig(
126
+ name=name,
127
+ url=url,
128
+ auth_type=auth_enum,
129
+ auth_value=auth_value,
130
+ enabled=not disabled,
131
+ )
132
+ store.add(config)
133
+
134
+ console.print(f"[green]✓[/green] Added MCP server '{name}'")
135
+ console.print(f" URL: {url}")
136
+ console.print(f" Auth: {auth_enum.value}")
137
+ console.print(f" Enabled: {not disabled}")
138
+
139
+
140
+ @mcp_app.command("rm")
141
+ def remove_server(
142
+ name: Annotated[str, typer.Argument(help="Name of the server to remove")],
143
+ force: Annotated[
144
+ bool,
145
+ typer.Option("--force", "-f", help="Skip confirmation"),
146
+ ] = False,
147
+ ) -> None:
148
+ """Remove an MCP server configuration."""
149
+ store = MCPServerStore()
150
+
151
+ server = store.get(name)
152
+ if not server:
153
+ console.print(f"[red]Server '{name}' not found.[/red]")
154
+ raise typer.Exit(1)
155
+
156
+ if not force:
157
+ confirm = typer.confirm(f"Remove MCP server '{name}'?")
158
+ if not confirm:
159
+ console.print("[dim]Cancelled.[/dim]")
160
+ raise typer.Exit(0)
161
+
162
+ store.remove(name)
163
+ console.print(f"[green]✓[/green] Removed MCP server '{name}'")
164
+
165
+
166
+ @mcp_app.command("status")
167
+ def check_status() -> None:
168
+ """Check connectivity status of all MCP servers."""
169
+ store = MCPServerStore()
170
+ servers = store.list_all()
171
+
172
+ if not servers:
173
+ console.print("[yellow]No MCP servers configured.[/yellow]")
174
+ return
175
+
176
+ table = Table(title="MCP Server Status")
177
+ table.add_column("Name", style="cyan")
178
+ table.add_column("URL", style="white")
179
+ table.add_column("Status", style="white")
180
+ table.add_column("Tools", style="blue")
181
+
182
+ async def check_all() -> list[tuple[MCPServerConfig, bool, str, int]]:
183
+ results = []
184
+ for server in servers:
185
+ if not server.enabled:
186
+ results.append((server, False, "Disabled", 0))
187
+ continue
188
+
189
+ manager = MCPToolManager(server)
190
+ try:
191
+ connected, status_msg = await manager.check_status()
192
+ tool_count = 0
193
+ if connected:
194
+ tools = await manager.get_tools()
195
+ tool_count = len(tools)
196
+ results.append((server, connected, status_msg, tool_count))
197
+ except Exception as e:
198
+ results.append((server, False, str(e)[:50], 0))
199
+ finally:
200
+ await manager.close()
201
+ return results
202
+
203
+ with console.status("[cyan]Checking servers...", spinner="dots"):
204
+ results = asyncio.run(check_all())
205
+
206
+ for server, connected, status_msg, tool_count in results:
207
+ status_style = "green" if connected else "red"
208
+ status_icon = "✓" if connected else "✗"
209
+ table.add_row(
210
+ server.name,
211
+ server.url,
212
+ f"[{status_style}]{status_icon} {status_msg}[/{status_style}]",
213
+ str(tool_count) if tool_count else "-",
214
+ )
215
+
216
+ console.print(table)
217
+
218
+
219
+ @mcp_app.command("enable")
220
+ def enable_server(
221
+ name: Annotated[str, typer.Argument(help="Name of the server to enable")],
222
+ ) -> None:
223
+ """Enable an MCP server."""
224
+ store = MCPServerStore()
225
+ server = store.get(name)
226
+
227
+ if not server:
228
+ console.print(f"[red]Server '{name}' not found.[/red]")
229
+ raise typer.Exit(1)
230
+
231
+ server.enabled = True
232
+ store.add(server)
233
+ console.print(f"[green]✓[/green] Enabled MCP server '{name}'")
234
+
235
+
236
+ @mcp_app.command("disable")
237
+ def disable_server(
238
+ name: Annotated[str, typer.Argument(help="Name of the server to disable")],
239
+ ) -> None:
240
+ """Disable an MCP server."""
241
+ store = MCPServerStore()
242
+ server = store.get(name)
243
+
244
+ if not server:
245
+ console.print(f"[red]Server '{name}' not found.[/red]")
246
+ raise typer.Exit(1)
247
+
248
+ server.enabled = False
249
+ store.add(server)
250
+ console.print(f"[yellow]✓[/yellow] Disabled MCP server '{name}'")
251
+
252
+
253
+ @mcp_app.command("set-auth")
254
+ def set_auth(
255
+ name: Annotated[str, typer.Argument(help="Name of the server")],
256
+ auth_type: Annotated[
257
+ str,
258
+ typer.Option("--type", "-t", help="Auth type: none, bearer, api_key"),
259
+ ] = "bearer",
260
+ token: Annotated[
261
+ str | None,
262
+ typer.Option("--token", help="Auth token or API key"),
263
+ ] = None,
264
+ ) -> None:
265
+ """Set authentication for an MCP server."""
266
+ store = MCPServerStore()
267
+ server = store.get(name)
268
+
269
+ if not server:
270
+ console.print(f"[red]Server '{name}' not found.[/red]")
271
+ raise typer.Exit(1)
272
+
273
+ try:
274
+ auth_enum = MCPAuthType(auth_type.lower())
275
+ except ValueError:
276
+ console.print(f"[red]Invalid auth type: {auth_type}[/red]")
277
+ raise typer.Exit(1)
278
+
279
+ server.auth_type = auth_enum
280
+ server.auth_value = token
281
+ store.add(server)
282
+
283
+ console.print(f"[green]✓[/green] Updated auth for '{name}'")
284
+ console.print(f" Type: {auth_enum.value}")
285
+ console.print(f" Token: {'set' if token else 'not set'}")
286
+
287
+
288
+ # OAuth callback handler
289
+ _oauth_result: dict[str, str | None] = {"code": None, "error": None}
290
+
291
+
292
+ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
293
+ """Handle OAuth callback from authorization server."""
294
+
295
+ def do_GET(self) -> None:
296
+ """Handle callback GET request."""
297
+ global _oauth_result
298
+
299
+ parsed = urlparse(self.path)
300
+ params = parse_qs(parsed.query)
301
+
302
+ if "code" in params:
303
+ _oauth_result["code"] = params["code"][0]
304
+ self.send_response(200)
305
+ self.send_header("Content-Type", "text/html")
306
+ self.end_headers()
307
+ self.wfile.write(
308
+ b"<html><body><h1>Authorization successful!</h1>"
309
+ b"<p>You can close this window and return to the terminal.</p>"
310
+ b"</body></html>"
311
+ )
312
+ elif "error" in params:
313
+ _oauth_result["error"] = params.get("error_description", params["error"])[0]
314
+ self.send_response(400)
315
+ self.send_header("Content-Type", "text/html")
316
+ self.end_headers()
317
+ self.wfile.write(
318
+ f"<html><body><h1>Authorization failed</h1>"
319
+ f"<p>{_oauth_result['error']}</p></body></html>".encode()
320
+ )
321
+ else:
322
+ self.send_response(400)
323
+ self.end_headers()
324
+
325
+ def log_message(self, format: str, *args: object) -> None:
326
+ """Suppress logging."""
327
+ pass
328
+
329
+
330
+ @mcp_app.command("authorize")
331
+ def authorize_server(
332
+ name: Annotated[str, typer.Argument(help="Name of the server to authorize")],
333
+ client_id: Annotated[
334
+ str | None,
335
+ typer.Option("--client-id", "-c", help="OAuth client ID"),
336
+ ] = None,
337
+ scope: Annotated[
338
+ str | None,
339
+ typer.Option("--scope", "-s", help="OAuth scopes (space-separated)"),
340
+ ] = None,
341
+ ) -> None:
342
+ """Initiate OAuth authorization flow for an MCP server.
343
+
344
+ This command will:
345
+ 1. Discover the authorization server from the MCP server
346
+ 2. Open a browser for you to authorize
347
+ 3. Capture the callback and exchange the code for tokens
348
+ 4. Store the tokens for future use
349
+ """
350
+ global _oauth_result
351
+ _oauth_result = {"code": None, "error": None}
352
+
353
+ store = MCPServerStore()
354
+ server = store.get(name)
355
+
356
+ if not server:
357
+ console.print(f"[red]Server '{name}' not found.[/red]")
358
+ raise typer.Exit(1)
359
+
360
+ if client_id:
361
+ server.oauth_client_id = client_id
362
+ if scope:
363
+ server.oauth_scopes = scope.split()
364
+
365
+ async def run_oauth_flow() -> bool:
366
+ manager = MCPToolManager(server, store)
367
+
368
+ try:
369
+ # First, try to initialize to trigger 401 and discover auth server
370
+ console.print("[cyan]Discovering authorization server...[/cyan]")
371
+
372
+ try:
373
+ # Make a test request to get 401
374
+ await manager._send_request(
375
+ "initialize",
376
+ {
377
+ "protocolVersion": "2024-11-05",
378
+ "capabilities": {},
379
+ "clientInfo": {"name": "cade", "version": "1.0.0"},
380
+ },
381
+ )
382
+ console.print("[yellow]Server doesn't require authentication.[/yellow]")
383
+ return True
384
+ except Exception as e:
385
+ if "ToolAuthorizationRequired" not in str(type(e).__name__):
386
+ console.print(f"[red]Error: {e}[/red]")
387
+ return False
388
+
389
+ # Check if we got an auth URL
390
+ if not hasattr(manager, "_pending_code_verifier"):
391
+ console.print("[red]Failed to discover authorization server.[/red]")
392
+ return False
393
+
394
+ # Start local callback server
395
+ server_address = ("127.0.0.1", 9876)
396
+ httpd = http.server.HTTPServer(server_address, OAuthCallbackHandler)
397
+ httpd.timeout = 300 # 5 minute timeout
398
+
399
+ # Open browser
400
+ if manager._auth_server_metadata:
401
+ auth_url, _, _ = await manager._oauth_handler.start_oauth_flow(
402
+ manager._auth_server_metadata,
403
+ scope,
404
+ client_id or server.oauth_client_id,
405
+ )
406
+ console.print("\n[cyan]Opening browser for authorization...[/cyan]")
407
+ console.print(f"[dim]URL: {auth_url[:80]}...[/dim]\n")
408
+ webbrowser.open(auth_url)
409
+
410
+ console.print("[yellow]Waiting for authorization (5 min timeout)...[/yellow]")
411
+
412
+ # Handle one request
413
+ def handle_callback() -> None:
414
+ httpd.handle_request()
415
+
416
+ thread = threading.Thread(target=handle_callback)
417
+ thread.start()
418
+ thread.join(timeout=300)
419
+
420
+ if _oauth_result["error"]:
421
+ console.print(f"[red]Authorization failed: {_oauth_result['error']}[/red]")
422
+ return False
423
+
424
+ if not _oauth_result["code"]:
425
+ console.print("[red]No authorization code received.[/red]")
426
+ return False
427
+
428
+ # Exchange code for tokens
429
+ console.print("[cyan]Exchanging code for tokens...[/cyan]")
430
+ success = await manager.complete_oauth_flow(_oauth_result["code"])
431
+
432
+ if success:
433
+ console.print(f"[green]✓[/green] Successfully authorized '{name}'")
434
+ return True
435
+ else:
436
+ console.print("[red]Failed to exchange code for tokens.[/red]")
437
+ return False
438
+ else:
439
+ console.print("[red]No authorization server metadata available.[/red]")
440
+ return False
441
+
442
+ finally:
443
+ await manager.close()
444
+
445
+ success = asyncio.run(run_oauth_flow())
446
+ if not success:
447
+ raise typer.Exit(1)
448
+
449
+
450
+ @mcp_app.command("token-info")
451
+ def token_info(
452
+ name: Annotated[str, typer.Argument(help="Name of the server")],
453
+ ) -> None:
454
+ """Show OAuth token information for an MCP server."""
455
+ store = MCPServerStore()
456
+ server = store.get(name)
457
+
458
+ if not server:
459
+ console.print(f"[red]Server '{name}' not found.[/red]")
460
+ raise typer.Exit(1)
461
+
462
+ if server.auth_type != MCPAuthType.OAUTH or not server.oauth_tokens:
463
+ console.print(f"[yellow]Server '{name}' is not using OAuth authentication.[/yellow]")
464
+ return
465
+
466
+ tokens = server.oauth_tokens
467
+ console.print(f"[cyan]OAuth Token Info for '{name}':[/cyan]")
468
+ console.print(f" Token Type: {tokens.token_type}")
469
+ console.print(f" Access Token: {tokens.access_token[:20]}...")
470
+ console.print(f" Refresh Token: {'set' if tokens.refresh_token else 'not set'}")
471
+ if tokens.expires_at:
472
+ if tokens.is_expired():
473
+ console.print(f" [red]Expired: {tokens.expires_at}[/red]")
474
+ else:
475
+ console.print(f" Expires: {tokens.expires_at}")
476
+ if tokens.scope:
477
+ console.print(f" Scopes: {tokens.scope}")
@@ -0,0 +1,226 @@
1
+ """CLI commands for viewing and managing tools."""
2
+
3
+ import asyncio
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from cadecoder.core.constants import DEFAULT_AI_MODEL
13
+ from cadecoder.execution.orchestrator import create_orchestrator
14
+
15
+ # Create tool command group
16
+ tool_app = typer.Typer(
17
+ name="tool",
18
+ help="View and manage available tools",
19
+ no_args_is_help=False,
20
+ invoke_without_command=True,
21
+ )
22
+
23
+ console = Console()
24
+
25
+
26
+ @tool_app.callback()
27
+ def tool_callback(ctx: typer.Context) -> None:
28
+ """View available tools. Lists all tools by default."""
29
+ if ctx.invoked_subcommand is None:
30
+ # No subcommand provided, run list_tools
31
+ list_tools(source=None, search=None)
32
+
33
+
34
+ @tool_app.command("list")
35
+ def list_tools(
36
+ source: Annotated[
37
+ str | None,
38
+ typer.Option(
39
+ "--source",
40
+ "-s",
41
+ help="Filter by source: local, remote, mcp",
42
+ ),
43
+ ] = None,
44
+ search: Annotated[
45
+ str | None,
46
+ typer.Option(
47
+ "--search",
48
+ "-q",
49
+ help="Search tools by name",
50
+ ),
51
+ ] = None,
52
+ ) -> None:
53
+ """List all available tools."""
54
+
55
+ async def get_tools() -> tuple[list[dict], dict[str, str]]:
56
+ orchestrator = create_orchestrator(default_model=DEFAULT_AI_MODEL)
57
+ tools = await orchestrator.tool_manager.get_tools()
58
+
59
+ # Get source mapping
60
+ source_map = {}
61
+ if hasattr(orchestrator.tool_manager, "get_all_tool_info"):
62
+ for info in orchestrator.tool_manager.get_all_tool_info():
63
+ source_map[info["name"]] = info.get("source", "unknown")
64
+
65
+ return tools, source_map
66
+
67
+ with console.status("[cyan]Loading tools...", spinner="dots"):
68
+ tools, source_map = asyncio.run(get_tools())
69
+
70
+ # Filter by source
71
+ if source:
72
+ source_lower = source.lower()
73
+ tools = [
74
+ t
75
+ for t in tools
76
+ if source_map.get(t.get("function", {}).get("name", ""), "").lower() == source_lower
77
+ ]
78
+
79
+ # Filter by search
80
+ if search:
81
+ search_lower = search.lower()
82
+ tools = [
83
+ t
84
+ for t in tools
85
+ if search_lower in t.get("function", {}).get("name", "").lower()
86
+ or search_lower in t.get("function", {}).get("description", "").lower()
87
+ ]
88
+
89
+ if not tools:
90
+ console.print("[yellow]No tools found.[/yellow]")
91
+ return
92
+
93
+ # Single table with all tools
94
+ table = Table(title="Available Tools", show_lines=False)
95
+ table.add_column("Name", style="cyan", no_wrap=True)
96
+ table.add_column("Source", style="dim", no_wrap=True, width=8)
97
+ table.add_column("Description", style="white", max_width=55)
98
+
99
+ # Sort all tools by name
100
+ for tool in sorted(tools, key=lambda t: t.get("function", {}).get("name", "")):
101
+ func = tool.get("function", {})
102
+ name = func.get("name", "unknown")
103
+ desc = func.get("description", "")
104
+
105
+ # Get source
106
+ tool_source = source_map.get(name, "unknown")
107
+ source_label = {
108
+ "local": "local",
109
+ "remote": "arcade",
110
+ "mcp": "mcp",
111
+ }.get(tool_source, tool_source)
112
+
113
+ # Clean up description - remove source prefix
114
+ if desc.startswith("["):
115
+ bracket_end = desc.find("]")
116
+ if bracket_end != -1:
117
+ desc = desc[bracket_end + 1 :].strip()
118
+
119
+ # Cut at first newline
120
+ if "\n" in desc:
121
+ desc = desc.split("\n")[0].strip()
122
+
123
+ # Truncate long descriptions
124
+ if len(desc) > 55:
125
+ desc = desc[:52] + "..."
126
+
127
+ table.add_row(name, source_label, desc)
128
+
129
+ console.print(table)
130
+ console.print(f"\n[dim]Total: {len(tools)} tools[/dim]")
131
+
132
+
133
+ @tool_app.command("info")
134
+ def tool_info(
135
+ name: Annotated[str, typer.Argument(help="Name of the tool")],
136
+ ) -> None:
137
+ """Show detailed information about a tool."""
138
+
139
+ async def get_tool_details() -> tuple[dict | None, str | None]:
140
+ orchestrator = create_orchestrator(default_model=DEFAULT_AI_MODEL)
141
+ tools = await orchestrator.tool_manager.get_tools()
142
+
143
+ # Find the tool
144
+ tool = None
145
+ for t in tools:
146
+ if t.get("function", {}).get("name", "") == name:
147
+ tool = t
148
+ break
149
+
150
+ # Get source
151
+ source = None
152
+ if hasattr(orchestrator.tool_manager, "get_tool_source"):
153
+ source = orchestrator.tool_manager.get_tool_source(name)
154
+
155
+ return tool, source
156
+
157
+ with console.status("[cyan]Loading tool info...", spinner="dots"):
158
+ tool, source = asyncio.run(get_tool_details())
159
+
160
+ if not tool:
161
+ console.print(f"[red]Tool '{name}' not found.[/red]")
162
+ raise typer.Exit(1)
163
+
164
+ func = tool.get("function", {})
165
+ tool_name = func.get("name", "unknown")
166
+ description = func.get("description", "No description")
167
+ parameters = func.get("parameters", {})
168
+
169
+ # Build info display
170
+ content = Text()
171
+
172
+ # Name and source
173
+ content.append(f"{tool_name}\n", style="bold cyan")
174
+ if source:
175
+ source_display = {
176
+ "local": "Local",
177
+ "remote": "Arcade Cloud",
178
+ "mcp": "MCP Server",
179
+ }.get(source, source.title())
180
+ content.append(f"Source: {source_display}\n\n", style="dim")
181
+
182
+ # Description
183
+ # Clean up description prefix
184
+ if description.startswith("["):
185
+ bracket_end = description.find("]")
186
+ if bracket_end != -1:
187
+ description = description[bracket_end + 1 :].strip()
188
+
189
+ content.append("Description:\n", style="bold")
190
+ content.append(f"{description}\n\n", style="white")
191
+
192
+ # Parameters
193
+ props = parameters.get("properties", {})
194
+ required = set(parameters.get("required", []))
195
+
196
+ if props:
197
+ content.append("Parameters:\n", style="bold")
198
+ for param_name, param_info in props.items():
199
+ param_type = param_info.get("type", "any")
200
+ param_desc = param_info.get("description", "")
201
+ is_required = param_name in required
202
+
203
+ req_marker = " [required]" if is_required else ""
204
+ content.append(f" • {param_name}", style="cyan")
205
+ content.append(f" ({param_type}){req_marker}\n", style="dim")
206
+ if param_desc:
207
+ content.append(f" {param_desc}\n", style="white")
208
+ else:
209
+ content.append("Parameters: None\n", style="dim")
210
+
211
+ panel = Panel(
212
+ content,
213
+ title="Tool Info",
214
+ border_style="blue",
215
+ padding=(1, 2),
216
+ )
217
+ console.print(panel)
218
+
219
+
220
+ @tool_app.command("search")
221
+ def search_tools(
222
+ query: Annotated[str, typer.Argument(help="Search query")],
223
+ ) -> None:
224
+ """Search for tools by name or description."""
225
+ # Delegate to list with search parameter
226
+ list_tools(source=None, search=query)