"""Controlled runner for installed CLI Apps.""" # pyright: reportIncompatibleMethodOverride=false from __future__ import annotations from pathlib import Path from pydantic import Field from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.context import RequestContext, ToolContext from nanobot.agent.tools.schema import ( ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema, ) from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig from nanobot.apps.cli.utils import runtime_lines_for_request from nanobot.config_base import Base from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines from nanobot.security.workspace_access import current_tool_workspace class CliAppsToolConfig(Base): """CLI Apps tool configuration.""" enable: bool = True install_timeout: int = Field(default=300, ge=1, le=3600) run_timeout: int = Field(default=60, ge=1, le=600) catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400) @tool_parameters( tool_parameters_schema( required=["name"], name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."), args=ArraySchema( StringSchema("One command-line argument."), description="Arguments to pass to the CLI entry point. Do not include the entry point itself.", nullable=True, ), json=BooleanSchema( description="Whether to prepend --json when supported by the CLI.", default=False, nullable=True, ), working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True), timeout=IntegerSchema( description="Timeout in seconds for this CLI call.", minimum=1, maximum=600, nullable=True, ), ) ) class CliAppsTool(Tool): """Run an installed CLI-Anything or public CLI app through a controlled argv subprocess.""" config_key = "cli_apps" _scopes = {"core", "subagent"} @classmethod def config_cls(cls): return CliAppsToolConfig @classmethod def enabled(cls, ctx: ToolContext) -> bool: return ctx.config.cli_apps.enable @classmethod def create(cls, ctx: ToolContext) -> Tool: cfg = ctx.config.cli_apps return cls( workspace=Path(ctx.workspace), restrict_to_workspace=ctx.config.restrict_to_workspace, runtime=CliAppsRuntimeConfig( install_timeout=cfg.install_timeout, run_timeout=cfg.run_timeout, catalog_ttl_seconds=cfg.catalog_ttl_seconds, ), ) def __init__( self, *, workspace: Path, restrict_to_workspace: bool = False, runtime: CliAppsRuntimeConfig | None = None, ) -> None: self.workspace = workspace self.restrict_to_workspace = restrict_to_workspace self.runtime = runtime or CliAppsRuntimeConfig() @property def name(self) -> str: return "run_cli_app" @property def description(self) -> str: try: installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names() except Exception: installed = [] installed_note = ( f" Installed Settings CLI Apps: {', '.join(installed)}." if installed else " No Settings CLI Apps are currently installed." ) return ( "Run a CLI App that the user explicitly installed in Settings or attached as @app. " "Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; " "unknown names are rejected. Execution uses argv, not shell." + installed_note ) def runtime_context_provider(self): return self._provide_runtime_context async def _provide_runtime_context( self, request: RequestContext, ) -> RuntimeContextBlock | None: lines = runtime_lines_for_request( request.original_user_text or "", request.metadata, request.workspace or self.workspace, ) content = wrap_runtime_context_lines(lines) if not content: return None return RuntimeContextBlock(source="cli_apps", content=content) async def execute( self, name: str, args: list[str] | None = None, json: bool | None = False, working_dir: str | None = None, timeout: int | None = None, ) -> str: access = current_tool_workspace( self.workspace, restrict_to_workspace=self.restrict_to_workspace, ) workspace = access.project_path or self.workspace manager = CliAppManager(workspace=workspace, runtime=self.runtime) try: return manager.run( name, args=args or [], json_output=bool(json), working_dir=working_dir, timeout=timeout, restrict_to_workspace=access.restrict_to_workspace, ) except CliAppError as exc: return ToolResult.error(f"Error: {exc.message}")