From 5484d1f5d1f111a6a33f121981ae87fa16a60bc2 Mon Sep 17 00:00:00 2001 From: mohiit1502 Date: Fri, 21 Aug 2026 17:05:52 +0530 Subject: [PATCH] feat: initial agos-cli standalone repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted from the agos monorepo. Contains the full AGOS CLI: - cli/agos.py — main entry point with login, logout, whoami, ask, db commands - cli/command_surface.py — API-backed commands (agent, task, workflow, plugin, policy, integration) - cli/manual.py — rich interactive manual and help system Uses clean package imports (no sys.path hacks). Packaged as agos-cli with `agos` console script entry point. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 15 + README.md | 78 +++ cli/__init__.py | 0 cli/agos.py | 772 +++++++++++++++++++++++ cli/command_surface.py | 1367 ++++++++++++++++++++++++++++++++++++++++ cli/manual.py | 562 +++++++++++++++++ pyproject.toml | 21 + 7 files changed, 2815 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cli/__init__.py create mode 100644 cli/agos.py create mode 100644 cli/command_surface.py create mode 100644 cli/manual.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..967457e --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.pyo +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +.venv/ +venv/ +env/ +.env +.env.local +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ca737c --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# agos-cli + +The official command-line interface for [AGOS](https://agos.armco.dev) — an agent orchestrator and runtime platform. + +## Installation + +```bash +pip install -e . +``` + +This registers the `agos` console script globally. + +## Quick Start + +```bash +# Authenticate with Armco IAM +agos login + +# Check who you are +agos whoami + +# Ask the platform a question +agos ask "What agents are currently running?" + +# List agents +agos agent list + +# Run a workflow +agos workflow run wf_123 --agent-id agent_123 +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `agos login` | Authenticate via Armco IAM (PKCE browser flow) | +| `agos logout` | Clear the local session | +| `agos whoami` | Show current identity and tenant | +| `agos ask ` | Send a natural-language prompt to AGOS chat | +| `agos agent ` | Manage agents (list, get, create, run, stop, delete) | +| `agos task ` | Manage tasks (list, get, create, cancel, delete) | +| `agos workflow ` | Manage workflows (list, get, create, run, status) | +| `agos plugin ` | Manage plugins (list, search, install, enable, disable, uninstall) | +| `agos policy ` | Manage policies (list, create, delete) | +| `agos integration ` | Manage integrations (catalog, providers, instances, create, test) | +| `agos db ` | Local database ops (bootstrap, seed, status) | +| `agos system ` | System health and logs | +| `agos help [topic]` | Focused help for any command | +| `agos man [topic]` | Full interactive manual | + +## Configuration + +The CLI reads API URL from the following env vars in order: + +- `AGOS_CLI_API_URL` +- `AGOS_API_URL` +- `VITE_API_URL` +- `AGOS_BASE_URL` +- Default: `http://localhost:2000` + +Auth tokens are cached at `~/.agos/auth.json` (mode `0600`). + +## Development + +```bash +# Clone and install in editable mode +git clone +cd agos-cli +pip install -e . + +# Run directly +agos --help +``` + +## Related Repos + +- [agos](https://gitea.armco.dev/agos/agos) — backend runtime +- [agos-client](https://gitea.armco.dev/agos/agos-client) — React frontend diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/agos.py b/cli/agos.py new file mode 100644 index 0000000..792dd1b --- /dev/null +++ b/cli/agos.py @@ -0,0 +1,772 @@ +#!/usr/bin/env python3 +"""AGOS CLI - Main entry point.""" + +import click +from rich.console import Console +from rich.table import Table +import sys +import os +import base64 +import hashlib +import json +import secrets +import threading +import time +import uuid +import webbrowser +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import urlencode, urlparse, parse_qs + +import requests +from dotenv import load_dotenv + +from cli.command_surface import install_api_commands +from cli.manual import install_manual_commands + +# `agos` is installed as a first-class console script (see pyproject.toml +# project.scripts). A non-editable `pip install` copies cli/agos.py into +# site-packages, so __file__-relative paths never reach the actual AGOS +# checkout. Resolve .env files from the canonical checkout locations that +# install.sh itself creates (AGOS_DIR / AGOS_CLIENT_DIR), plus the +# in-repo dev path (this file's own repo, for `python -m cli.agos` usage) +# and cwd (for running the CLI from inside either checkout). +_PACKAGE_REPO_DIR = Path(__file__).resolve().parent.parent +_AGOS_DIR = Path(os.environ.get('AGOS_DIR', Path.home() / '.agos')) +_AGOS_CLIENT_DIR = Path(os.environ.get( + 'AGOS_CLIENT_DIR', _AGOS_DIR.parent / 'agos-client', +)) +_CWD = Path.cwd() + +_ENV_CANDIDATE_DIRS = [ + _AGOS_DIR, + _AGOS_CLIENT_DIR, + _PACKAGE_REPO_DIR, + _PACKAGE_REPO_DIR.parent / 'agos-client', + _CWD, + _CWD.parent / 'agos-client', +] + +for _env_dir in _ENV_CANDIDATE_DIRS: + load_dotenv(_env_dir / '.env', override=False) +for _env_dir in _ENV_CANDIDATE_DIRS: + load_dotenv(_env_dir / '.env.local', override=True) + +console = Console() +_AUTH_DIR = Path.home() / '.agos' +_AUTH_FILE = _AUTH_DIR / 'auth.json' + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _emit_log( + level, + operation, + correlation_id, + entity_id='agos_cli', + metadata=None, + component='agos.cli.auth', +): + payload = { + 'timestamp': _utc_now_iso(), + 'level': level, + 'component': component, + 'operation': operation, + 'entity_id': entity_id, + 'correlation_id': correlation_id, + 'metadata': metadata or {}, + } + click.echo(json.dumps(payload), err=True) + + +def _first_env(*keys): + for key in keys: + value = os.getenv(key, '').strip() + if value: + return value + return '' + + +def _parse_scopes(raw_scopes): + normalized = (raw_scopes or '').replace(',', ' ') + scopes = [scope.strip() for scope in normalized.split() if scope.strip()] + return scopes or ['openid', 'profile', 'email', 'offline_access'] + + +def _pkce_verifier(): + return secrets.token_urlsafe(64) + + +def _pkce_challenge(verifier): + digest = hashlib.sha256(verifier.encode('utf-8')).digest() + return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('utf-8') + + +def _fetch_discovery(issuer, correlation_id): + issuer_base = issuer.rstrip('/') + discovery_url = f'{issuer_base}/.well-known/openid-configuration' + _emit_log('INFO', 'fetch_discovery_start', correlation_id, metadata={'issuer': issuer_base}) + response = requests.get(discovery_url, timeout=15) + response.raise_for_status() + discovery = response.json() + _emit_log( + 'INFO', + 'fetch_discovery_ok', + correlation_id, + metadata={ + 'issuer': issuer_base, + 'authorization_endpoint': discovery.get('authorization_endpoint'), + 'token_endpoint': discovery.get('token_endpoint'), + }, + ) + return discovery + + +def _write_auth_state(payload): + _AUTH_DIR.mkdir(parents=True, exist_ok=True) + _AUTH_FILE.write_text(json.dumps(payload, indent=2), encoding='utf-8') + os.chmod(_AUTH_FILE, 0o600) + + +def _start_callback_server(host, port, expected_state, timeout_seconds, correlation_id): + callback_event = threading.Event() + callback_payload = {} + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + callback_payload['path'] = parsed.path + callback_payload['code'] = params.get('code', [None])[0] + callback_payload['state'] = params.get('state', [None])[0] + callback_payload['error'] = params.get('error', [None])[0] + callback_payload['error_description'] = params.get('error_description', [None])[0] + callback_payload['received_at'] = _utc_now_iso() + callback_payload['valid_state'] = callback_payload.get('state') == expected_state + body = ( + '

Agos CLI login complete.

' + '

You can return to the terminal.

' + if callback_payload['valid_state'] and not callback_payload.get('error') + else '

Agos CLI login failed.

' + '

Return to the terminal for details.

' + ) + status = 200 if callback_payload['valid_state'] else 400 + self.send_response(status) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.end_headers() + self.wfile.write(body.encode('utf-8')) + callback_event.set() + + def log_message(self, format, *args): + return + + server = HTTPServer((host, port), CallbackHandler) + server.timeout = 0.5 + + def serve(): + deadline = time.time() + timeout_seconds + while time.time() < deadline and not callback_event.is_set(): + server.handle_request() + server.server_close() + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + _emit_log( + 'INFO', + 'callback_server_started', + correlation_id, + metadata={'host': host, 'port': port, 'timeout_seconds': timeout_seconds}, + ) + return callback_event, callback_payload, thread + + +def _exchange_code_for_tokens(discovery, client_id, code, redirect_uri, code_verifier, correlation_id): + payload = { + 'grant_type': 'authorization_code', + 'client_id': client_id, + 'code': code, + 'redirect_uri': redirect_uri, + 'code_verifier': code_verifier, + } + _emit_log( + 'INFO', + 'token_exchange_start', + correlation_id, + metadata={'token_endpoint': discovery.get('token_endpoint'), 'client_id': client_id}, + ) + response = requests.post( + discovery['token_endpoint'], + data=payload, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=15, + ) + if not response.ok: + try: + error_payload = response.json() + except Exception: + error_payload = {'error': response.text[:200]} + raise click.ClickException( + error_payload.get('error_description') + or error_payload.get('error') + or f'Token exchange failed with status {response.status_code}' + ) + tokens = response.json() + _emit_log( + 'INFO', + 'token_exchange_ok', + correlation_id, + metadata={'expires_in': tokens.get('expires_in'), 'has_refresh_token': bool(tokens.get('refresh_token'))}, + ) + return tokens + + +def _fetch_userinfo(discovery, access_token, correlation_id): + userinfo_endpoint = discovery.get('userinfo_endpoint') + if not userinfo_endpoint: + return None + _emit_log('INFO', 'userinfo_fetch_start', correlation_id, metadata={'userinfo_endpoint': userinfo_endpoint}) + response = requests.get( + userinfo_endpoint, + headers={'Authorization': f'Bearer {access_token}'}, + timeout=15, + ) + if not response.ok: + _emit_log('WARN', 'userinfo_fetch_failed', correlation_id, metadata={'status_code': response.status_code}) + return None + userinfo = response.json() + _emit_log( + 'INFO', + 'userinfo_fetch_ok', + correlation_id, + metadata={'subject': userinfo.get('sub'), 'email': userinfo.get('email')}, + ) + return userinfo + + +@click.group() +@click.version_option(version='1.0.0', prog_name='AGOS') +def cli(): + """AGOS - Agent Runtime Platform CLI""" + pass + + +@cli.command() +@click.option('--issuer', default=None, help='Armco IAM issuer URL') +@click.option('--client-id', default=None, help='IAM OAuth client ID for the CLI/native app') +@click.option( + '--host', + default='127.0.0.1', + show_default=True, + help='Loopback host for the local callback listener', +) +@click.option( + '--port', + default=8976, + show_default=True, + type=int, + help='Loopback port for the local callback listener', +) +@click.option( + '--timeout', + 'timeout_seconds', + default=180, + show_default=True, + type=int, + help='Seconds to wait for the browser login callback', +) +@click.option('--scopes', default=None, help='Space or comma separated OAuth scopes') +def login(issuer, client_id, host, port, timeout_seconds, scopes): + """Login with Armco IAM for Agos CLI.""" + correlation_id = f'cli_login_{uuid.uuid4().hex[:12]}' + resolved_issuer = issuer or _first_env('AGOS_CLI_IAM_ISSUER', 'VITE_IAM_ISSUER', 'IAM_ISSUER') + resolved_client_id = client_id or _first_env('AGOS_CLI_IAM_CLIENT_ID', 'VITE_IAM_CLIENT_ID') + resolved_scopes = _parse_scopes(scopes or _first_env('AGOS_CLI_IAM_SCOPES')) + redirect_uri = f'http://{host}:{port}/callback' + + if not resolved_issuer: + raise click.ClickException('Missing IAM issuer. Set AGOS_CLI_IAM_ISSUER, VITE_IAM_ISSUER, or pass --issuer.') + + if not resolved_client_id: + raise click.ClickException('Missing CLI IAM client ID. Set AGOS_CLI_IAM_CLIENT_ID or pass --client-id.') + + if os.getenv('AGOS_CLI_IAM_CLIENT_ID', '').strip() == '' and os.getenv('VITE_IAM_CLIENT_ID', '').strip(): + console.print( + '[yellow]Using VITE_IAM_CLIENT_ID for CLI login. ' + 'Ensure IAM allows the loopback redirect URI for this client.[/yellow]' + ) + + _emit_log( + 'INFO', + 'login_start', + correlation_id, + metadata={ + 'issuer': resolved_issuer, + 'client_id': resolved_client_id, + 'redirect_uri': redirect_uri, + 'scopes': resolved_scopes, + }, + ) + + try: + discovery = _fetch_discovery(resolved_issuer, correlation_id) + state = secrets.token_urlsafe(24) + nonce = secrets.token_urlsafe(24) + code_verifier = _pkce_verifier() + code_challenge = _pkce_challenge(code_verifier) + callback_event, callback_payload, thread = _start_callback_server( + host=host, + port=port, + expected_state=state, + timeout_seconds=timeout_seconds, + correlation_id=correlation_id, + ) + + authorize_params = { + 'client_id': resolved_client_id, + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': ' '.join(resolved_scopes), + 'state': state, + 'nonce': nonce, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256', + } + authorize_url = f"{discovery['authorization_endpoint']}?{urlencode(authorize_params)}" + browser_opened = webbrowser.open(authorize_url) + _emit_log( + 'INFO', + 'browser_opened', + correlation_id, + metadata={'browser_opened': browser_opened, 'authorization_endpoint': discovery['authorization_endpoint']}, + ) + + console.print('[bold blue]Opening Armco IAM login in your browser...[/bold blue]') + console.print(f'[dim]If the browser does not open, use this URL:[/dim]\n{authorize_url}') + + if not callback_event.wait(timeout=timeout_seconds): + raise click.ClickException('Timed out waiting for the IAM login callback.') + + thread.join(timeout=1) + + if callback_payload.get('error'): + raise click.ClickException( + callback_payload.get('error_description') or callback_payload['error'] + ) + + if not callback_payload.get('valid_state'): + raise click.ClickException('IAM callback state validation failed.') + + code = callback_payload.get('code') + if not code: + raise click.ClickException('IAM callback did not include an authorization code.') + + tokens = _exchange_code_for_tokens( + discovery=discovery, + client_id=resolved_client_id, + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + correlation_id=correlation_id, + ) + userinfo = _fetch_userinfo(discovery, tokens['access_token'], correlation_id) + auth_state = { + 'timestamp': _utc_now_iso(), + 'issuer': resolved_issuer.rstrip('/'), + 'client_id': resolved_client_id, + 'redirect_uri': redirect_uri, + 'scopes': resolved_scopes, + 'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('refresh_token'), + 'id_token': tokens.get('id_token'), + 'token_type': tokens.get('token_type'), + 'expires_in': tokens.get('expires_in'), + 'expires_at': int(time.time()) + int(tokens.get('expires_in', 0) or 0), + 'user': userinfo, + } + _write_auth_state(auth_state) + _emit_log( + 'INFO', + 'login_completed', + correlation_id, + entity_id=(userinfo or {}).get('sub', 'agos_cli'), + metadata={ + 'auth_file': str(_AUTH_FILE), + 'email': (userinfo or {}).get('email'), + 'has_refresh_token': bool(tokens.get('refresh_token')), + }, + ) + console.print('[bold green]✓ Login successful[/bold green]') + if userinfo: + identity_label = userinfo.get('email') or userinfo.get('username') or userinfo.get('sub') + console.print(f'Authenticated as: {identity_label}') + console.print(f'Token cache: {_AUTH_FILE}') + except Exception as exc: + _emit_log('ERROR', 'login_failed', correlation_id, metadata={'error': str(exc)}) + if isinstance(exc, click.ClickException): + raise + raise click.ClickException(str(exc)) from exc + + +@cli.group() +def db(): + """Database management commands""" + pass + + +@db.command() +@click.option('--host', default='localhost', help='PostgreSQL host') +@click.option('--port', default=5432, help='PostgreSQL port') +@click.option('--database', default='agos', help='Database name') +@click.option('--user', default='postgres', help='Database user') +@click.password_option('--password', help='Database password') +def db_bootstrap_command(host, port, database, user, password): + """Bootstrap database schema (idempotent)""" + import asyncpg + import asyncio + + async def run_bootstrap(): + try: + console.print("[bold blue]Bootstrapping AGOS database...[/bold blue]") + + conn = await asyncpg.connect( + host=host, + port=port, + database=database, + user=user, + password=password + ) + + bootstrap_path = os.path.join( + os.path.dirname(__file__), '..', 'seeders', 'bootstrap.sql' + ) + + with open(bootstrap_path, 'r') as f: + sql = f.read() + + await conn.execute(sql) + await conn.close() + + console.print("[bold green]✓ Database bootstrap completed successfully![/bold green]") + + except Exception as e: + console.print(f"[bold red]✗ Bootstrap failed: {e}[/bold red]") + sys.exit(1) + + asyncio.run(run_bootstrap()) + + +@db.command() +@click.option('--host', default='localhost', help='PostgreSQL host') +@click.option('--port', default=5432, help='PostgreSQL port') +@click.option('--database', default='agos', help='Database name') +@click.option('--user', default='postgres', help='Database user') +@click.password_option('--password', help='Database password') +def db_seed_command(host, port, database, user, password): + """Seed database with test data""" + import asyncpg + import asyncio + + async def run_seed(): + try: + console.print("[bold blue]Seeding AGOS database...[/bold blue]") + + conn = await asyncpg.connect( + host=host, + port=port, + database=database, + user=user, + password=password + ) + + seed_path = os.path.join( + os.path.dirname(__file__), '..', 'seeders', 'seed.sql' + ) + + with open(seed_path, 'r') as f: + sql = f.read() + + await conn.execute(sql) + await conn.close() + + console.print("[bold green]✓ Database seeded successfully![/bold green]") + + except Exception as e: + console.print(f"[bold red]✗ Seed failed: {e}[/bold red]") + sys.exit(1) + + asyncio.run(run_seed()) + + +@db.command() +@click.option('--host', default='localhost', help='PostgreSQL host') +@click.option('--port', default=5432, help='PostgreSQL port') +@click.option('--database', default='agos', help='Database name') +@click.option('--user', default='postgres', help='Database user') +@click.password_option('--password', help='Database password') +def db_status_command(host, port, database, user, password): + """Check database status""" + import asyncpg + import asyncio + + async def check_status(): + try: + console.print("[bold blue]Checking database status...[/bold blue]") + + conn = await asyncpg.connect( + host=host, + port=port, + database=database, + user=user, + password=password + ) + + # Check tables + tables = await conn.fetch(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name + """) + + # Check row counts + agent_count = await conn.fetchval("SELECT COUNT(*) FROM agents") + task_count = await conn.fetchval("SELECT COUNT(*) FROM tasks") + plugin_count = await conn.fetchval("SELECT COUNT(*) FROM plugins") + + await conn.close() + + table = Table(title="Database Status") + table.add_column("Metric", style="cyan") + table.add_column("Value", style="green") + + table.add_row("Tables", str(len(tables))) + table.add_row("Agents", str(agent_count)) + table.add_row("Tasks", str(task_count)) + table.add_row("Plugins", str(plugin_count)) + + console.print(table) + console.print("[bold green]✓ Database is healthy[/bold green]") + + except Exception as e: + console.print(f"[bold red]✗ Status check failed: {e}[/bold red]") + sys.exit(1) + + asyncio.run(check_status()) + + +@cli.group() +def agent(): + """Agent management commands""" + pass + + +@agent.command() +@click.option('--name', required=True, help='Agent name') +@click.option('--description', help='Agent description') +@click.option('--model', default='gpt-4', help='LLM model') +def agent_create_stub(name, description, model): + """Create a new agent""" + console.print(f"[bold blue]Creating agent: {name}[/bold blue]") + console.print("[yellow]Note: Full agent creation requires API implementation[/yellow]") + console.print(f" Name: {name}") + console.print(f" Description: {description or 'N/A'}") + console.print(f" Model: {model}") + + +@agent.command() +def agent_list_stub(): + """List all agents""" + console.print("[bold blue]Listing agents...[/bold blue]") + console.print("[yellow]Note: Full agent listing requires API implementation[/yellow]") + + +@agent.command() +@click.argument('agent_id') +def agent_start_stub(agent_id): + """Start an agent""" + console.print(f"[bold blue]Starting agent: {agent_id}[/bold blue]") + console.print("[yellow]Note: Full agent start requires runtime implementation[/yellow]") + + +@agent.command() +@click.argument('agent_id') +def agent_stop_stub(agent_id): + """Stop an agent""" + console.print(f"[bold blue]Stopping agent: {agent_id}[/bold blue]") + console.print("[yellow]Note: Full agent stop requires runtime implementation[/yellow]") + + +@agent.command() +@click.argument('agent_id') +@click.confirmation_option(prompt='Are you sure you want to delete this agent?') +def agent_delete_stub(agent_id): + """Delete an agent""" + console.print(f"[bold red]Deleting agent: {agent_id}[/bold red]") + console.print("[yellow]Note: Full agent deletion requires API implementation[/yellow]") + + +@cli.group() +def plugin(): + """Plugin management commands""" + pass + + +@plugin.command() +@click.argument('plugin_name') +def plugin_install_stub(plugin_name): + """Install a plugin""" + console.print(f"[bold blue]Installing plugin: {plugin_name}[/bold blue]") + console.print("[yellow]Note: Full plugin install requires implementation[/yellow]") + + +@plugin.command() +def plugin_list_stub(): + """List installed plugins""" + console.print("[bold blue]Listing plugins...[/bold blue]") + console.print("[yellow]Note: Full plugin listing requires API implementation[/yellow]") + + +@plugin.command() +@click.argument('query') +def plugin_search_stub(query): + """Search marketplace for plugins""" + console.print(f"[bold blue]Searching for: {query}[/bold blue]") + console.print("[yellow]Note: Full search requires marketplace API[/yellow]") + + +@plugin.command() +@click.argument('plugin_id') +@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?') +def plugin_uninstall_stub(plugin_id): + """Uninstall a plugin""" + console.print(f"[bold red]Uninstalling plugin: {plugin_id}[/bold red]") + console.print("[yellow]Note: Full uninstall requires implementation[/yellow]") + + +@cli.group() +def workflow(): + """Workflow management commands""" + pass + + +@workflow.command() +@click.option('--name', required=True, help='Workflow name') +@click.option('--file', type=click.Path(exists=True), help='Workflow definition file') +def workflow_create_stub(name, file): + """Create a new workflow""" + console.print(f"[bold blue]Creating workflow: {name}[/bold blue]") + if file: + console.print(f" From file: {file}") + console.print("[yellow]Note: Full workflow creation requires API implementation[/yellow]") + + +@workflow.command() +@click.argument('workflow_id') +def workflow_run_stub(workflow_id): + """Execute a workflow""" + console.print(f"[bold blue]Running workflow: {workflow_id}[/bold blue]") + console.print("[yellow]Note: Full workflow execution requires runtime[/yellow]") + + +@workflow.command() +@click.argument('workflow_id') +def workflow_status_stub(workflow_id): + """Check workflow status""" + console.print(f"[bold blue]Checking status of workflow: {workflow_id}[/bold blue]") + console.print("[yellow]Note: Full status check requires API implementation[/yellow]") + + +@cli.group() +def policy(): + """Policy management commands""" + pass + + +@policy.command() +@click.option('--name', required=True, help='Policy name') +@click.option('--type', 'policy_type', required=True, + type=click.Choice(['filesystem', 'network', 'resource'])) +@click.option('--effect', required=True, + type=click.Choice(['allow', 'deny', 'require_approval'])) +def policy_create_stub(name, policy_type, effect): + """Create a new policy""" + console.print(f"[bold blue]Creating policy: {name}[/bold blue]") + console.print(f" Type: {policy_type}") + console.print(f" Effect: {effect}") + console.print("[yellow]Note: Full policy creation requires API implementation[/yellow]") + + +@policy.command() +def policy_list_stub(): + """List all policies""" + console.print("[bold blue]Listing policies...[/bold blue]") + console.print("[yellow]Note: Full policy listing requires API implementation[/yellow]") + + +@policy.command() +@click.argument('policy_id') +@click.confirmation_option(prompt='Are you sure you want to delete this policy?') +def policy_delete_stub(policy_id): + """Delete a policy""" + console.print(f"[bold red]Deleting policy: {policy_id}[/bold red]") + console.print("[yellow]Note: Full policy deletion requires API implementation[/yellow]") + + +@cli.group() +def system(): + """System operations""" + pass + + +@system.command() +def system_status_stub(): + """Show system status""" + console.print("[bold blue]System Status[/bold blue]") + + table = Table(title="AGOS Status") + table.add_column("Component", style="cyan") + table.add_column("Status", style="green") + + table.add_row("Database", "✓ Connected") + table.add_row("Runtime", "⚠ Not Started") + table.add_row("API Server", "⚠ Not Started") + table.add_row("Plugin System", "✓ Ready") + + console.print(table) + + +@system.command() +@click.option('--lines', default=50, help='Number of log lines to show') +def system_logs_stub(lines): + """Show system logs""" + console.print(f"[bold blue]Showing last {lines} log lines...[/bold blue]") + console.print("[yellow]Note: Full log viewing requires implementation[/yellow]") + + +@system.command() +def system_metrics_stub(): + """Show system metrics""" + console.print("[bold blue]System Metrics[/bold blue]") + console.print("[yellow]Note: Full metrics require observability implementation[/yellow]") + + +install_api_commands( + cli=cli, + agent_group=agent, + plugin_group=plugin, + workflow_group=workflow, + policy_group=policy, + system_group=system, + console=console, + emit_log=_emit_log, + auth_file=_AUTH_FILE, +) +install_manual_commands(cli=cli, console=console) + + +if __name__ == '__main__': + cli() + diff --git a/cli/command_surface.py b/cli/command_surface.py new file mode 100644 index 0000000..822894c --- /dev/null +++ b/cli/command_surface.py @@ -0,0 +1,1367 @@ +import hashlib +import json +import os +import time +from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Sequence +from urllib.parse import urlparse + +import click +import requests +from rich.panel import Panel +from rich.table import Table + + +def install_api_commands( + cli, + agent_group, + plugin_group, + workflow_group, + policy_group, + system_group, + console, + emit_log, + auth_file: Path, +): + def api_url_option(func): + return click.option('--api-url', default=None)(func) + + def json_output_option(func): + return click.option('--json-output', is_flag=True, default=False)(func) + + def _emit(level: str, operation: str, correlation_id: str, entity_id: str = 'agos_cli', metadata: Optional[Dict[str, Any]] = None): + emit_log( + level, + operation, + correlation_id, + entity_id=entity_id, + metadata=metadata, + component='agos.cli.api', + ) + + def _first_env(*keys: str) -> str: + for key in keys: + value = os.getenv(key, '').strip() + if value: + return value + return '' + + def _normalize_api_url(value: Optional[str]) -> str: + raw = (value or '').strip() + if not raw: + return '' + if not raw.startswith('http://') and not raw.startswith('https://'): + raw = f'https://{raw}' + normalized = raw.rstrip('/') + if normalized.endswith('/api/v1'): + normalized = normalized[:-7] + return normalized + + def _resolve_api_url(explicit: Optional[str] = None) -> str: + return _normalize_api_url( + explicit + or _first_env( + 'AGOS_CLI_API_URL', + 'AGOS_API_URL', + 'VITE_API_URL', + 'AGOS_BASE_URL', + ) + or 'http://localhost:2000' + ) + + def _is_local_api_url(api_url: str) -> bool: + hostname = (urlparse(api_url).hostname or '').lower() + return hostname in {'localhost', '127.0.0.1', '0.0.0.0'} + + def _api_target_label(api_url: str) -> str: + return 'Local API' if _is_local_api_url(api_url) else 'Cloud API' + + def _api_base_path(api_url: str) -> str: + normalized = _normalize_api_url(api_url) + return f'{normalized}/api/v1' + + def _read_auth_state() -> Dict[str, Any]: + if not auth_file.exists(): + raise click.ClickException( + 'You are not logged in yet. Run `agos login` before using authenticated commands.' + ) + try: + return json.loads(auth_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, OSError) as exc: + raise click.ClickException( + f'Unable to read your stored Agos session at {auth_file}. Run `agos login` again.' + ) from exc + + def _write_auth_state(state: Dict[str, Any]) -> None: + auth_file.parent.mkdir(parents=True, exist_ok=True) + auth_file.write_text(json.dumps(state, indent=2), encoding='utf-8') + os.chmod(auth_file, 0o600) + + def _refresh_auth_state(state: Dict[str, Any], correlation_id: str) -> Dict[str, Any]: + refresh_token = (state.get('refresh_token') or '').strip() + issuer = (state.get('issuer') or '').strip() + client_id = (state.get('client_id') or '').strip() + if not refresh_token or not issuer or not client_id: + raise click.ClickException( + 'Your saved session cannot be refreshed. Run `agos login` again.' + ) + discovery_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration" + _emit('INFO', 'session_refresh_start', correlation_id, metadata={'issuer': issuer}) + try: + discovery_response = requests.get(discovery_url, timeout=15) + discovery_response.raise_for_status() + discovery = discovery_response.json() + token_response = requests.post( + discovery['token_endpoint'], + data={ + 'grant_type': 'refresh_token', + 'client_id': client_id, + 'refresh_token': refresh_token, + }, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=15, + ) + except requests.RequestException as exc: + _emit('ERROR', 'session_refresh_failed', correlation_id, metadata={'error': str(exc)}) + raise click.ClickException( + 'Agos CLI could not refresh your session. Check your network connection or run `agos login` again.' + ) from exc + if not token_response.ok: + try: + payload = token_response.json() + except ValueError: + payload = {'error': token_response.text[:200]} + _emit('ERROR', 'session_refresh_failed', correlation_id, metadata={'status_code': token_response.status_code, 'error': payload.get('error')}) + raise click.ClickException( + payload.get('error_description') + or payload.get('error') + or 'Agos CLI could not refresh your session. Run `agos login` again.' + ) + tokens = token_response.json() + updated = dict(state) + updated['access_token'] = tokens.get('access_token') + updated['refresh_token'] = tokens.get('refresh_token') or refresh_token + updated['id_token'] = tokens.get('id_token') or state.get('id_token') + updated['token_type'] = tokens.get('token_type') or state.get('token_type') + updated['expires_in'] = tokens.get('expires_in') + updated['expires_at'] = int(time.time()) + int(tokens.get('expires_in', 0) or 0) + _write_auth_state(updated) + _emit( + 'INFO', + 'session_refresh_ok', + correlation_id, + metadata={'has_refresh_token': bool(updated.get('refresh_token')), 'expires_in': updated.get('expires_in')}, + ) + return updated + + def _ensure_access_token(correlation_id: str) -> Dict[str, Any]: + state = _read_auth_state() + access_token = (state.get('access_token') or '').strip() + expires_at = int(state.get('expires_at', 0) or 0) + if access_token and expires_at > int(time.time()) + 60: + return state + if state.get('refresh_token'): + return _refresh_auth_state(state, correlation_id) + raise click.ClickException( + 'Your Agos session has expired. Run `agos login` again.' + ) + + def _parse_payload(response: requests.Response) -> Any: + if not response.content: + return None + try: + return response.json() + except ValueError: + return response.text.strip() + + def _format_error_message(status_code: int, payload: Any) -> str: + detail = payload.get('detail') if isinstance(payload, dict) else None + error_code = None + message = None + if isinstance(detail, dict): + error_code = detail.get('code') or detail.get('error') + message = detail.get('message') or detail.get('detail') + elif isinstance(detail, str): + message = detail + elif isinstance(payload, dict): + error_code = payload.get('error') + message = payload.get('message') or payload.get('detail') + elif isinstance(payload, str): + message = payload + if error_code == 'feature_not_installed': + slug = detail.get('slug') if isinstance(detail, dict) else None + name = detail.get('name') if isinstance(detail, dict) else 'This feature' + return ( + f'{name} is not installed on this Agos instance. ' + f'Install it first with `agos installable install {slug}`.' + ) + if error_code == 'quota_exceeded': + upgrade_options = payload.get('upgrade_options') or {} + quota_used = payload.get('quota_used') + quota_limit = payload.get('quota_limit') + extra = [] + if quota_used is not None and quota_limit is not None: + extra.append(f'Runs used: {quota_used}/{quota_limit}.') + if upgrade_options.get('settings_url'): + extra.append(f"Open {upgrade_options['settings_url']} to connect your own provider.") + return ' '.join(filter(None, [message, *extra])) + if status_code == 401: + return 'Authentication failed. Run `agos login` again to refresh your session.' + if status_code == 403: + return message or 'This command requires additional permissions or an admin role.' + if status_code == 404: + return message or 'The requested resource was not found.' + if status_code == 402: + return message or 'Your Agos quota has been exhausted.' + return message or f'Agos API request failed with status {status_code}.' + + def _request_api( + method: str, + path: str, + correlation_id: str, + api_url: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + expected_status: Sequence[int] = (200,), + require_auth: bool = True, + retry_on_401: bool = True, + ) -> Any: + resolved_api_url = _resolve_api_url(api_url) + url = f"{_api_base_path(resolved_api_url)}{path}" + headers = {'Accept': 'application/json'} + auth_state = None + if require_auth: + auth_state = _ensure_access_token(correlation_id) + headers['Authorization'] = f"Bearer {auth_state['access_token']}" + if json_body is not None: + headers['Content-Type'] = 'application/json' + _emit('INFO', 'api_request_start', correlation_id, metadata={'method': method.upper(), 'path': path, 'api_url': resolved_api_url}) + try: + response = requests.request( + method=method.upper(), + url=url, + headers=headers, + params=params, + json=json_body, + timeout=20, + ) + except requests.RequestException as exc: + _emit('ERROR', 'api_request_failed', correlation_id, metadata={'method': method.upper(), 'path': path, 'api_url': resolved_api_url, 'error': str(exc)}) + raise click.ClickException( + f'Unable to reach the Agos API at {resolved_api_url}. Make sure the API is running and reachable.' + ) from exc + if response.status_code == 401 and require_auth and retry_on_401 and auth_state and auth_state.get('refresh_token'): + auth_state = _refresh_auth_state(auth_state, correlation_id) + headers['Authorization'] = f"Bearer {auth_state['access_token']}" + response = requests.request( + method=method.upper(), + url=url, + headers=headers, + params=params, + json=json_body, + timeout=20, + ) + payload = _parse_payload(response) + if response.status_code not in expected_status: + _emit('ERROR', 'api_request_failed', correlation_id, metadata={'method': method.upper(), 'path': path, 'api_url': resolved_api_url, 'status_code': response.status_code}) + raise click.ClickException(_format_error_message(response.status_code, payload)) + _emit('INFO', 'api_request_ok', correlation_id, metadata={'method': method.upper(), 'path': path, 'api_url': resolved_api_url, 'status_code': response.status_code}) + return payload + + def _read_cli_chat_session_id() -> Optional[str]: + state = _read_auth_state() + raw = state.get('active_cli_chat_session_id') + if not isinstance(raw, str): + return None + value = raw.strip() + return value or None + + def _write_cli_chat_session_id(session_id: str) -> None: + state = _read_auth_state() + state['active_cli_chat_session_id'] = session_id + _write_auth_state(state) + + def _parse_json_arg(raw: Optional[str], label: str) -> Dict[str, Any]: + if not raw: + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise click.ClickException(f'Invalid JSON for {label}: {exc}') from exc + if not isinstance(parsed, dict): + raise click.ClickException(f'{label} must be a JSON object.') + return parsed + + def _parse_json_file(path_value: Optional[str], label: str) -> Dict[str, Any]: + if not path_value: + return {} + try: + raw = Path(path_value).read_text(encoding='utf-8') + except OSError as exc: + raise click.ClickException(f'Unable to read {label} file: {path_value}') from exc + return _parse_json_arg(raw, label) + + def _print_json(payload: Any) -> None: + console.print_json(data=json.dumps(payload, default=str)) + + def _stringify(value: Any) -> str: + if value is None or value == '': + return '—' + if isinstance(value, bool): + return 'yes' if value else 'no' + if isinstance(value, (list, tuple)): + return ', '.join(str(item) for item in value) if value else '—' + if isinstance(value, dict): + return json.dumps(value, sort_keys=True) + return str(value) + + def _render_table(title: str, columns: Sequence[tuple[str, str]], items: Iterable[Dict[str, Any]]) -> None: + rows = list(items) + if not rows: + console.print(Panel.fit('No results found.', title=title)) + return + table = Table(title=title) + for column_title, _ in columns: + table.add_column(column_title) + for item in rows: + table.add_row(*[_stringify(item.get(key)) for _, key in columns]) + console.print(table) + + def _show_target(api_url: str) -> None: + console.print(f'[dim]{_api_target_label(api_url)}: {api_url}[/dim]') + + @cli.command(name='whoami') + @api_url_option + @json_output_option + def whoami_command(api_url: Optional[str], json_output: bool): + correlation_id = f'cli_whoami_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', '/me', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + table = Table(title='Current Identity') + table.add_column('Field', style='cyan') + table.add_column('Value', style='green') + table.add_row('User ID', _stringify(payload.get('user_id'))) + table.add_row('Email', _stringify(payload.get('email'))) + table.add_row('Name', _stringify(payload.get('name'))) + table.add_row('Roles', _stringify(payload.get('roles'))) + table.add_row('Scopes', _stringify(payload.get('scopes'))) + table.add_row('Tenant', _stringify(payload.get('tenant_id'))) + table.add_row('Auth Method', _stringify(payload.get('auth_method'))) + table.add_row('Admin', _stringify(payload.get('is_admin'))) + console.print(table) + + @cli.command(name='quota') + @api_url_option + @click.option('--history', is_flag=True, default=False) + @json_output_option + def quota_command(api_url: Optional[str], history: bool, json_output: bool): + correlation_id = f'cli_quota_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + if history: + payload = _request_api('GET', '/quota/history', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Quota History', + [ + ('Run ID', 'id'), + ('Task ID', 'task_id'), + ('Agent ID', 'agent_id'), + ('Plan', 'plan_at_run'), + ('Tokens', 'tokens_used'), + ('Cost USD', 'cost_usd'), + ('Created', 'created_at'), + ], + payload.get('items', []), + ) + return + payload = _request_api('GET', '/quota', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + table = Table(title='Quota Status') + table.add_column('Field', style='cyan') + table.add_column('Value', style='green') + table.add_row('Plan', _stringify(payload.get('plan'))) + table.add_row('Runs Used', _stringify(payload.get('runs_used'))) + table.add_row('Runs Limit', _stringify(payload.get('runs_limit'))) + table.add_row('Runs Remaining', _stringify(payload.get('runs_remaining'))) + table.add_row('Tokens Used', _stringify(payload.get('tokens_used_total'))) + table.add_row('Platform Funded', _stringify(payload.get('is_platform_funded'))) + console.print(table) + + @cli.command(name='logout') + def logout_command(): + if auth_file.exists(): + auth_file.unlink() + console.print(Panel.fit(f'Your Agos session has been cleared from {auth_file}.', title='Logged Out')) + return + console.print(Panel.fit('No local Agos session was found.', title='Logged Out')) + + @cli.command(name='clear') + def clear_command(): + click.echo('\033[2J\033[H', nl=False) + + @cli.command(name='ask') + @click.argument('message', nargs=-1) + @api_url_option + @json_output_option + def ask_command(message: Sequence[str], api_url: Optional[str], json_output: bool): + correlation_id = f'cli_ask_{int(time.time() * 1000)}' + prompt = ' '.join(message).strip() + if not prompt: + raise click.ClickException('Usage: agos ask ') + resolved_api_url = _resolve_api_url(api_url) + chat_session_id = _read_cli_chat_session_id() + _emit( + 'INFO', + 'ask_command_start', + correlation_id, + metadata={ + 'api_url': resolved_api_url, + 'prompt_length': len(prompt), + 'has_cached_session': bool(chat_session_id), + }, + ) + if not chat_session_id: + session_payload = _request_api( + 'POST', + '/chat/sessions', + correlation_id, + api_url=resolved_api_url, + json_body={'title': prompt[:60]}, + ) + chat_session_id = session_payload.get('session_id') if isinstance(session_payload, dict) else None + if not chat_session_id: + raise click.ClickException('Agos did not return a chat session id for this CLI conversation.') + _write_cli_chat_session_id(chat_session_id) + payload = _request_api( + 'POST', + '/chat', + correlation_id, + api_url=resolved_api_url, + json_body={'message': prompt, 'session_id': chat_session_id}, + ) + response_session_id = payload.get('session_id') if isinstance(payload, dict) else None + if ( + isinstance(response_session_id, str) + and response_session_id.strip() + and response_session_id != chat_session_id + ): + chat_session_id = response_session_id.strip() + _write_cli_chat_session_id(chat_session_id) + _emit( + 'INFO', + 'ask_command_ok', + correlation_id, + metadata={ + 'api_url': resolved_api_url, + 'chat_session_id': chat_session_id, + 'has_reply': bool(payload.get('reply')) if isinstance(payload, dict) else False, + }, + ) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + reply = payload.get('reply') if isinstance(payload, dict) else None + if not reply: + console.print(Panel.fit('Agos returned an empty reply.', title='Agos Chat')) + return + console.print(Panel.fit(str(reply), title='Agos Chat')) + + @click.group(name='installable') + def installable_group(): + pass + + cli.add_command(installable_group) + + @installable_group.command(name='list') + @api_url_option + @json_output_option + def installable_list_command(api_url: Optional[str], json_output: bool): + correlation_id = f'cli_installables_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', '/installables', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Installables', + [('Slug', 'slug'), ('Name', 'name'), ('Kind', 'kind'), ('Installed', 'installed'), ('Route', 'route')], + payload, + ) + + @installable_group.command(name='install') + @click.argument('slug') + @api_url_option + def installable_install_command(slug: str, api_url: Optional[str]): + correlation_id = f'cli_installable_install_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/installables/{slug}/install', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Installed {payload['state']['name']} ({payload['slug']}).", title='Installable Installed')) + + @installable_group.command(name='uninstall') + @click.argument('slug') + @api_url_option + def installable_uninstall_command(slug: str, api_url: Optional[str]): + correlation_id = f'cli_installable_uninstall_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/installables/{slug}/uninstall', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Uninstalled {payload['state']['name']} ({payload['slug']}).", title='Installable Uninstalled')) + + @agent_group.command(name='list') + @api_url_option + @click.option('--page', default=1, type=int) + @click.option('--page-size', default=20, type=int) + @click.option('--status', 'status_filter', default=None) + @click.option('--ephemeral', default=None, type=bool) + @json_output_option + def agent_list_command(api_url: Optional[str], page: int, page_size: int, status_filter: Optional[str], ephemeral: Optional[bool], json_output: bool): + correlation_id = f'cli_agent_list_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'page': page, 'page_size': page_size} + if status_filter: + params['status'] = status_filter + if ephemeral is not None: + params['ephemeral'] = str(ephemeral).lower() + payload = _request_api('GET', '/agents', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Agents', + [ + ('ID', 'id'), + ('Name', 'name'), + ('Status', 'status'), + ('Type', 'agent_type'), + ('Model', 'model'), + ('Last Task', 'last_task_status'), + ('Executions', 'execution_count'), + ], + payload.get('items', []), + ) + + @agent_group.command(name='get') + @click.argument('agent_id') + @api_url_option + @json_output_option + def agent_get_command(agent_id: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_agent_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/agents/{agent_id}', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _print_json(payload) + + @agent_group.command(name='create') + @click.option('--name', required=True) + @click.option('--model', required=True) + @click.option('--agent-type', default='workflow') + @click.option('--system-prompt', default=None) + @click.option('--capability', 'capabilities', multiple=True) + @click.option('--dag-strategy', default='llm') + @click.option('--workflow-id', default=None) + @click.option('--model-provider-id', default=None) + @click.option('--rag-corpus-id', default=None) + @click.option('--guard-check', 'guard_checks', multiple=True) + @click.option('--ephemeral', is_flag=True, default=False) + @click.option('--config-json', default=None) + @api_url_option + @json_output_option + def agent_create_command(name: str, model: str, agent_type: str, system_prompt: Optional[str], capabilities: Sequence[str], dag_strategy: str, workflow_id: Optional[str], model_provider_id: Optional[str], rag_corpus_id: Optional[str], guard_checks: Sequence[str], ephemeral: bool, config_json: Optional[str], api_url: Optional[str], json_output: bool): + correlation_id = f'cli_agent_create_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + body = { + 'name': name, + 'model': model, + 'agent_type': agent_type, + 'system_prompt': system_prompt, + 'configuration': _parse_json_arg(config_json, 'config-json'), + 'capabilities': list(capabilities), + 'dag_strategy': dag_strategy, + 'workflow_id': workflow_id, + 'model_provider_id': model_provider_id, + 'rag_corpus_id': rag_corpus_id, + 'guard_checks': list(guard_checks), + 'ephemeral': ephemeral, + } + payload = _request_api('POST', '/agents', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Created agent {payload['name']} ({payload['id']}).", title='Agent Created')) + + @agent_group.command(name='start') + @click.argument('agent_id') + @api_url_option + def agent_start_command(agent_id: str, api_url: Optional[str]): + correlation_id = f'cli_agent_start_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/agents/{agent_id}/resume', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Agent {payload['agent_id']} is now {payload['state']}.", title='Agent Resumed')) + + @agent_group.command(name='stop') + @click.argument('agent_id') + @api_url_option + def agent_stop_command(agent_id: str, api_url: Optional[str]): + correlation_id = f'cli_agent_stop_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/agents/{agent_id}/pause', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Agent {payload['agent_id']} is now {payload['state']}.", title='Agent Paused')) + + @agent_group.command(name='delete') + @click.argument('agent_id') + @click.confirmation_option(prompt='Are you sure you want to delete this agent?') + @api_url_option + def agent_delete_command(agent_id: str, api_url: Optional[str]): + correlation_id = f'cli_agent_delete_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + _request_api('DELETE', f'/agents/{agent_id}', correlation_id, api_url=resolved_api_url, expected_status=(204,)) + _show_target(resolved_api_url) + console.print(Panel.fit(f'Agent {agent_id} has been deleted.', title='Agent Deleted')) + + @agent_group.command(name='run') + @click.argument('agent_id') + @click.option('--goal', required=True) + @click.option('--max-reasoning-turns', default=10, type=int) + @api_url_option + @json_output_option + def agent_run_command(agent_id: str, goal: str, max_reasoning_turns: int, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_agent_run_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api( + 'POST', + f'/agents/{agent_id}/run', + correlation_id, + api_url=resolved_api_url, + json_body={'goal': goal, 'max_reasoning_turns': max_reasoning_turns}, + expected_status=(201,), + ) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Queued task {payload['task_id']} for agent {payload['agent_id']}.", title='Agent Run Started')) + + @click.group(name='task') + def task_group(): + pass + + cli.add_command(task_group) + + @task_group.command(name='list') + @api_url_option + @click.option('--page', default=1, type=int) + @click.option('--page-size', default=20, type=int) + @click.option('--agent-id', default=None) + @click.option('--status', 'status_filter', default=None) + @json_output_option + def task_list_command(api_url: Optional[str], page: int, page_size: int, agent_id: Optional[str], status_filter: Optional[str], json_output: bool): + correlation_id = f'cli_task_list_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'page': page, 'page_size': page_size} + if agent_id: + params['agent_id'] = agent_id + if status_filter: + params['status'] = status_filter + payload = _request_api('GET', '/tasks', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Tasks', + [('ID', 'id'), ('Agent ID', 'agent_id'), ('Status', 'status'), ('Type', 'task_type'), ('Priority', 'priority'), ('Created', 'created_at')], + payload.get('items', []), + ) + + @task_group.command(name='get') + @click.argument('task_id') + @api_url_option + @json_output_option + def task_get_command(task_id: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_task_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/tasks/{task_id}', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _print_json(payload) + + @task_group.command(name='create') + @click.option('--agent-id', required=True) + @click.option('--task-type', default='workflow_step') + @click.option('--description', required=True) + @click.option('--input-json', default=None) + @click.option('--priority', default=100, type=int) + @click.option('--max-retries', default=3, type=int) + @click.option('--timeout-seconds', default=300, type=int) + @click.option('--idempotency-key', default=None) + @api_url_option + @json_output_option + def task_create_command(agent_id: str, task_type: str, description: str, input_json: Optional[str], priority: int, max_retries: int, timeout_seconds: int, idempotency_key: Optional[str], api_url: Optional[str], json_output: bool): + correlation_id = f'cli_task_create_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + input_payload = _parse_json_arg(input_json, 'input-json') if input_json else {} + input_payload.setdefault('description', description) + body = { + 'agent_id': agent_id, + 'task_type': task_type, + 'input': input_payload, + 'priority': priority, + 'max_retries': max_retries, + 'timeout_seconds': timeout_seconds, + 'idempotency_key': idempotency_key, + } + payload = _request_api('POST', '/tasks', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Queued task {payload['id']} for agent {payload['agent_id']}.", title='Task Created')) + + @task_group.command(name='cancel') + @click.argument('task_id') + @api_url_option + def task_cancel_command(task_id: str, api_url: Optional[str]): + correlation_id = f'cli_task_cancel_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/tasks/{task_id}/cancel', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Task {payload['task_id']} is now {payload['status']}.", title='Task Cancelled')) + + @task_group.command(name='delete') + @click.argument('task_id') + @click.confirmation_option(prompt='Are you sure you want to delete this task?') + @api_url_option + def task_delete_command(task_id: str, api_url: Optional[str]): + correlation_id = f'cli_task_delete_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + _request_api('DELETE', f'/tasks/{task_id}', correlation_id, api_url=resolved_api_url, expected_status=(204,)) + _show_target(resolved_api_url) + console.print(Panel.fit(f'Task {task_id} has been deleted.', title='Task Deleted')) + + @workflow_group.command(name='list') + @api_url_option + @click.option('--page', default=1, type=int) + @click.option('--page-size', default=20, type=int) + @click.option('--status', default=None) + @click.option('--search', default=None) + @json_output_option + def workflow_list_command(api_url: Optional[str], page: int, page_size: int, status: Optional[str], search: Optional[str], json_output: bool): + correlation_id = f'cli_workflow_list_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'page': page, 'page_size': page_size} + if status: + params['status'] = status + if search: + params['search'] = search + payload = _request_api('GET', '/workflows', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Workflows', + [('ID', 'id'), ('Name', 'name'), ('Status', 'status'), ('Executions', 'execution_count'), ('Last Status', 'last_execution_status'), ('Updated', 'updated_at')], + payload.get('items', []), + ) + + @workflow_group.command(name='get') + @click.argument('workflow_id') + @api_url_option + @json_output_option + def workflow_get_command(workflow_id: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_workflow_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/workflows/{workflow_id}', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _print_json(payload) + + @workflow_group.command(name='create') + @click.option('--name', required=True) + @click.option('--description', default=None) + @click.option('--definition-file', type=click.Path(exists=True), default=None) + @click.option('--definition-json', default=None) + @click.option('--status', 'workflow_status', default='draft') + @api_url_option + @json_output_option + def workflow_create_command(name: str, description: Optional[str], definition_file: Optional[str], definition_json: Optional[str], workflow_status: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_workflow_create_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + definition = _parse_json_file(definition_file, 'definition-file') if definition_file else {} + if definition_json: + definition = _parse_json_arg(definition_json, 'definition-json') + body = { + 'name': name, + 'description': description, + 'definition': definition, + 'status': workflow_status, + } + payload = _request_api('POST', '/workflows', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Created workflow {payload['name']} ({payload['id']}).", title='Workflow Created')) + + @workflow_group.command(name='run') + @click.argument('workflow_id') + @click.option('--agent-id', required=True) + @click.option('--parameters-json', default=None) + @api_url_option + @json_output_option + def workflow_run_command(workflow_id: str, agent_id: str, parameters_json: Optional[str], api_url: Optional[str], json_output: bool): + correlation_id = f'cli_workflow_run_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + body = { + 'agent_id': agent_id, + 'parameters': _parse_json_arg(parameters_json, 'parameters-json'), + } + payload = _request_api('POST', f'/workflows/{workflow_id}/run', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Started workflow execution {payload['id']} for workflow {payload['workflow_id']}.", title='Workflow Running')) + + @workflow_group.command(name='status') + @click.argument('workflow_id') + @click.option('--limit', default=20, type=int) + @api_url_option + @json_output_option + def workflow_status_command(workflow_id: str, limit: int, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_workflow_status_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/workflows/{workflow_id}/executions', correlation_id, api_url=resolved_api_url, params={'limit': limit}) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + f'Workflow Executions: {workflow_id}', + [('Execution ID', 'id'), ('Agent ID', 'agent_id'), ('Status', 'status'), ('Started', 'started_at'), ('Completed', 'completed_at')], + payload, + ) + + @plugin_group.command(name='list') + @api_url_option + @click.option('--page', default=1, type=int) + @click.option('--page-size', default=20, type=int) + @click.option('--trust-level', default=None) + @click.option('--verified-only', is_flag=True, default=False) + @click.option('--tags', default=None) + @click.option('--source-type', default=None) + @json_output_option + def plugin_list_command(api_url: Optional[str], page: int, page_size: int, trust_level: Optional[str], verified_only: bool, tags: Optional[str], source_type: Optional[str], json_output: bool): + correlation_id = f'cli_plugin_list_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'page': page, 'page_size': page_size, 'verified_only': str(verified_only).lower()} + if trust_level: + params['trust_level'] = trust_level + if tags: + params['tags'] = tags + if source_type: + params['source_type'] = source_type + payload = _request_api('GET', '/plugins', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Plugins', + [('ID', 'id'), ('Name', 'name'), ('Version', 'version'), ('Enabled', 'enabled'), ('Trust', 'trust_level'), ('Verified', 'verified'), ('Scan', 'scan_status')], + payload.get('items', []), + ) + + @plugin_group.command(name='get') + @click.argument('plugin_id') + @api_url_option + @json_output_option + def plugin_get_command(plugin_id: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_plugin_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/plugins/{plugin_id}', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _print_json(payload) + + @plugin_group.command(name='search') + @click.argument('query') + @api_url_option + @json_output_option + def plugin_search_command(query: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_plugin_search_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', '/marketplace/plugins', correlation_id, api_url=resolved_api_url, params={'search': query}) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Marketplace Plugins', + [('ID', 'id'), ('Name', 'name'), ('Version', 'version'), ('Author', 'author'), ('Downloads', 'downloads'), ('Rating', 'avg_rating')], + payload.get('items', []), + ) + + @plugin_group.command(name='install') + @click.argument('plugin_name') + @click.option('--version', required=True) + @click.option('--author', required=True) + @click.option('--manifest-file', type=click.Path(exists=True), required=True) + @click.option('--description', default=None) + @click.option('--checksum', default=None) + @click.option('--tag', 'tags', multiple=True) + @click.option('--license', 'plugin_license', default='Proprietary') + @api_url_option + @json_output_option + def plugin_install_command(plugin_name: str, version: str, author: str, manifest_file: str, description: Optional[str], checksum: Optional[str], tags: Sequence[str], plugin_license: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_plugin_install_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + manifest_path = Path(manifest_file) + manifest = _parse_json_file(manifest_file, 'manifest-file') + computed_checksum = checksum or hashlib.sha256(manifest_path.read_bytes()).hexdigest() + body = { + 'name': plugin_name, + 'version': version, + 'author': author, + 'description': description, + 'manifest': manifest, + 'checksum': computed_checksum, + 'tags': list(tags), + 'license': plugin_license, + } + payload = _request_api('POST', '/plugins', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Installed plugin {payload['name']} ({payload['id']}).", title='Plugin Installed')) + + @plugin_group.command(name='enable') + @click.argument('plugin_id') + @api_url_option + def plugin_enable_command(plugin_id: str, api_url: Optional[str]): + correlation_id = f'cli_plugin_enable_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/plugins/{plugin_id}/enable', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Plugin {payload['plugin_id']} enabled.", title='Plugin Enabled')) + + @plugin_group.command(name='disable') + @click.argument('plugin_id') + @api_url_option + def plugin_disable_command(plugin_id: str, api_url: Optional[str]): + correlation_id = f'cli_plugin_disable_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/plugins/{plugin_id}/disable', correlation_id, api_url=resolved_api_url) + _show_target(resolved_api_url) + console.print(Panel.fit(f"Plugin {payload['plugin_id']} disabled.", title='Plugin Disabled')) + + @plugin_group.command(name='uninstall') + @click.argument('plugin_id') + @click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?') + @api_url_option + def plugin_uninstall_command(plugin_id: str, api_url: Optional[str]): + correlation_id = f'cli_plugin_uninstall_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + _request_api('DELETE', f'/plugins/{plugin_id}', correlation_id, api_url=resolved_api_url, expected_status=(204,)) + _show_target(resolved_api_url) + console.print(Panel.fit(f'Plugin {plugin_id} has been uninstalled.', title='Plugin Uninstalled')) + + @policy_group.command(name='list') + @api_url_option + @click.option('--page', default=1, type=int) + @click.option('--page-size', default=20, type=int) + @click.option('--policy-type', default=None) + @click.option('--enabled-only', is_flag=True, default=False) + @click.option('--agent-id', default=None) + @json_output_option + def policy_list_command(api_url: Optional[str], page: int, page_size: int, policy_type: Optional[str], enabled_only: bool, agent_id: Optional[str], json_output: bool): + correlation_id = f'cli_policy_list_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'page': page, 'page_size': page_size, 'enabled_only': str(enabled_only).lower()} + if policy_type: + params['policy_type'] = policy_type + if agent_id: + params['agent_id'] = agent_id + payload = _request_api('GET', '/policies', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Policies', + [('ID', 'id'), ('Name', 'name'), ('Type', 'policy_type'), ('Enabled', 'enabled'), ('Priority', 'priority'), ('Agent ID', 'agent_id')], + payload.get('items', []), + ) + + @policy_group.command(name='create') + @click.option('--name', required=True) + @click.option('--type', 'policy_type', required=True) + @click.option('--description', default=None) + @click.option('--conditions-json', default='{}') + @click.option('--priority', default=0, type=int) + @click.option('--enabled/--disabled', default=True) + @click.option('--owner-id', default=None) + @click.option('--agent-id', default=None) + @api_url_option + @json_output_option + def policy_create_command(name: str, policy_type: str, description: Optional[str], conditions_json: str, priority: int, enabled: bool, owner_id: Optional[str], agent_id: Optional[str], api_url: Optional[str], json_output: bool): + correlation_id = f'cli_policy_create_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + body = { + 'name': name, + 'description': description, + 'policy_type': policy_type, + 'conditions': _parse_json_arg(conditions_json, 'conditions-json'), + 'priority': priority, + 'enabled': enabled, + 'owner_id': owner_id, + 'agent_id': agent_id, + } + payload = _request_api('POST', '/policies', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Created policy {payload['name']} ({payload['id']}).", title='Policy Created')) + + @policy_group.command(name='delete') + @click.argument('policy_id') + @click.confirmation_option(prompt='Are you sure you want to delete this policy?') + @api_url_option + def policy_delete_command(policy_id: str, api_url: Optional[str]): + correlation_id = f'cli_policy_delete_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + _request_api('DELETE', f'/policies/{policy_id}', correlation_id, api_url=resolved_api_url, expected_status=(204,)) + _show_target(resolved_api_url) + console.print(Panel.fit(f'Policy {policy_id} has been deleted.', title='Policy Deleted')) + + @click.group(name='integration') + def integration_group(): + pass + + cli.add_command(integration_group) + + @integration_group.command(name='catalog') + @api_url_option + @click.option('--category', default=None) + @click.option('--status', default=None) + @click.option('--search', default=None) + @json_output_option + def integration_catalog_command(api_url: Optional[str], category: Optional[str], status: Optional[str], search: Optional[str], json_output: bool): + correlation_id = f'cli_integration_catalog_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {} + if category: + params['category'] = category + if status: + params['status'] = status + if search: + params['search'] = search + payload = _request_api('GET', '/integrations/catalog', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Integration Catalog', + [('Slug', 'slug'), ('Title', 'title'), ('Category', 'category'), ('Status', 'implementation_status'), ('Auth', 'auth_primary'), ('Provider', 'has_provider')], + payload, + ) + + @integration_group.command(name='providers') + @api_url_option + @click.option('--category', default=None) + @json_output_option + def integration_providers_command(api_url: Optional[str], category: Optional[str], json_output: bool): + correlation_id = f'cli_integration_providers_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'category': category} if category else None + payload = _request_api('GET', '/integrations/providers', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Integration Providers', + [('ID', 'id'), ('Name', 'name'), ('Slug', 'slug'), ('Category', 'category'), ('Auth', 'auth_type'), ('Enabled', 'is_enabled')], + payload, + ) + + @integration_group.command(name='instances') + @api_url_option + @click.option('--category', default=None) + @json_output_option + def integration_instances_command(api_url: Optional[str], category: Optional[str], json_output: bool): + correlation_id = f'cli_integration_instances_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + params = {'category': category} if category else None + payload = _request_api('GET', '/integrations/instances', correlation_id, api_url=resolved_api_url, params=params) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _render_table( + 'Integration Instances', + [('ID', 'id'), ('Name', 'name'), ('Provider', 'provider_slug'), ('Category', 'category'), ('Status', 'status'), ('Updated', 'updated_at')], + payload, + ) + + @integration_group.command(name='create') + @click.option('--provider-id', required=True) + @click.option('--name', required=True) + @click.option('--description', default=None) + @click.option('--config-json', default='{}') + @api_url_option + @json_output_option + def integration_create_command(provider_id: str, name: str, description: Optional[str], config_json: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_integration_create_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + body = { + 'provider_id': provider_id, + 'name': name, + 'description': description, + 'config': _parse_json_arg(config_json, 'config-json'), + } + payload = _request_api('POST', '/integrations/instances', correlation_id, api_url=resolved_api_url, json_body=body, expected_status=(201,)) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + console.print(Panel.fit(f"Created integration instance {payload['name']} ({payload['id']}).", title='Integration Created')) + + @integration_group.command(name='test') + @click.argument('instance_id') + @api_url_option + @json_output_option + def integration_test_command(instance_id: str, api_url: Optional[str], json_output: bool): + correlation_id = f'cli_integration_test_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('POST', f'/integrations/instances/{instance_id}/test', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + _print_json(payload) + + # ── network-tools policy (3-layer) ─────────────────────────────────────── + + @click.group(name='network-tools') + def network_tools_policy_group(): + """Manage the 3-layer network tools policy (global / user / agent). + + All three layers must be enabled for internet_search and web_scraper + to execute. Default state is 111 (all enabled). + + agos policy network-tools status + agos policy network-tools global set --enable / --disable + agos policy network-tools user set --user-id UID --enable / --disable + agos policy network-tools agent set --agent-id AID --enable / --disable + """ + + policy_group.add_command(network_tools_policy_group) + + @network_tools_policy_group.command(name='status') + @api_url_option + @json_output_option + def network_tools_status_command(api_url: Optional[str], json_output: bool): + """Show all three policy layers at once.""" + correlation_id = f'cli_ntp_status_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + global_data = _request_api('GET', '/admin/settings/network-tools-policy', correlation_id, api_url=resolved_api_url) + payload = {'global': global_data} + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + table = Table(title='Network Tools Policy Layers') + table.add_column('Layer', style='cyan') + table.add_column('Scope', style='dim') + table.add_column('Enabled', style='green') + table.add_row( + 'Layer 1 — Global', + 'All users & agents', + '✓ yes' if global_data.get('enabled') else '✗ NO', + ) + table.add_row('Layer 2 — User', 'Per user (use `user get --user-id UID`)', '—') + table.add_row('Layer 3 — Agent', 'Per agent (use `agent get --agent-id AID`)', '—') + console.print(table) + if not global_data.get('enabled'): + console.print('[bold red]⚠ Global layer is DISABLED — internet_search and web_scraper are blocked for all users.[/bold red]') + + @click.group(name='global') + def ntp_global_group(): + """Global (layer 1) network tools policy — affects all users and agents.""" + + network_tools_policy_group.add_command(ntp_global_group) + + @ntp_global_group.command(name='get') + @api_url_option + @json_output_option + def ntp_global_get(api_url: Optional[str], json_output: bool): + """Show the current global network tools policy (layer 1).""" + correlation_id = f'cli_ntp_global_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', '/admin/settings/network-tools-policy', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + status = '[green]enabled[/green]' if payload.get('enabled') else '[bold red]DISABLED[/bold red]' + console.print(f'Global network tools policy (layer 1): {status}') + + @ntp_global_group.command(name='set') + @click.option('--enable/--disable', required=True, help='Enable or disable network tools globally') + @api_url_option + def ntp_global_set(enable: bool, api_url: Optional[str]): + """Set the global network tools policy (layer 1).""" + correlation_id = f'cli_ntp_global_set_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api( + 'PUT', '/admin/settings/network-tools-policy', + correlation_id, api_url=resolved_api_url, + json_body={'enabled': enable}, + ) + _show_target(resolved_api_url) + action = '[green]enabled[/green]' if payload.get('enabled') else '[bold red]disabled[/bold red]' + console.print(f'Global network tools policy set to {action}.') + if not enable: + console.print('[yellow]All users and agents are now blocked from internet_search and web_scraper.[/yellow]') + + @click.group(name='user') + def ntp_user_group(): + """User-level (layer 2) network tools policy.""" + + network_tools_policy_group.add_command(ntp_user_group) + + @ntp_user_group.command(name='get') + @click.option('--user-id', required=True) + @api_url_option + @json_output_option + def ntp_user_get(user_id: str, api_url: Optional[str], json_output: bool): + """Show user-level network tools policy for a specific user (layer 2).""" + correlation_id = f'cli_ntp_user_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/admin/settings/network-tools-policy/users/{user_id}', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + status = '[green]enabled[/green]' if payload.get('enabled') else '[bold red]DISABLED[/bold red]' + console.print(f'User {user_id} network tools policy (layer 2): {status}') + + @ntp_user_group.command(name='set') + @click.option('--user-id', required=True) + @click.option('--enable/--disable', required=True) + @api_url_option + def ntp_user_set(user_id: str, enable: bool, api_url: Optional[str]): + """Set user-level network tools policy for a specific user (layer 2).""" + correlation_id = f'cli_ntp_user_set_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api( + 'PUT', f'/admin/settings/network-tools-policy/users/{user_id}', + correlation_id, api_url=resolved_api_url, + json_body={'enabled': enable}, + ) + _show_target(resolved_api_url) + action = '[green]enabled[/green]' if payload.get('enabled') else '[bold red]disabled[/bold red]' + console.print(f'User {user_id} network tools policy set to {action}.') + + @click.group(name='agent') + def ntp_agent_group(): + """Agent-level (layer 3) network tools policy.""" + + network_tools_policy_group.add_command(ntp_agent_group) + + @ntp_agent_group.command(name='get') + @click.option('--agent-id', required=True) + @api_url_option + @json_output_option + def ntp_agent_get(agent_id: str, api_url: Optional[str], json_output: bool): + """Show agent-level network tools policy (layer 3).""" + correlation_id = f'cli_ntp_agent_get_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api('GET', f'/admin/settings/network-tools-policy/agents/{agent_id}', correlation_id, api_url=resolved_api_url) + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + status = '[green]enabled[/green]' if payload.get('enabled') else '[bold red]DISABLED[/bold red]' + console.print(f'Agent {agent_id} network tools policy (layer 3): {status}') + + @ntp_agent_group.command(name='set') + @click.option('--agent-id', required=True) + @click.option('--enable/--disable', required=True) + @api_url_option + def ntp_agent_set(agent_id: str, enable: bool, api_url: Optional[str]): + """Set agent-level network tools policy (layer 3).""" + correlation_id = f'cli_ntp_agent_set_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + payload = _request_api( + 'PUT', f'/admin/settings/network-tools-policy/agents/{agent_id}', + correlation_id, api_url=resolved_api_url, + json_body={'enabled': enable}, + ) + _show_target(resolved_api_url) + action = '[green]enabled[/green]' if payload.get('enabled') else '[bold red]disabled[/bold red]' + console.print(f'Agent {agent_id} network tools policy set to {action}.') + + # ───────────────────────────────────────────────────────────────────────── + + @system_group.command(name='status') + @api_url_option + @json_output_option + def system_status_command(api_url: Optional[str], json_output: bool): + correlation_id = f'cli_system_status_{int(time.time() * 1000)}' + resolved_api_url = _resolve_api_url(api_url) + me_payload = _request_api('GET', '/me', correlation_id, api_url=resolved_api_url) + quota_payload = _request_api('GET', '/quota', correlation_id, api_url=resolved_api_url) + installables_payload = _request_api('GET', '/installables', correlation_id, api_url=resolved_api_url) + payload = { + 'api_url': resolved_api_url, + 'target': _api_target_label(resolved_api_url), + 'identity': me_payload, + 'quota': quota_payload, + 'installables': installables_payload, + } + if json_output: + _print_json(payload) + return + _show_target(resolved_api_url) + identity_table = Table(title='Agos System Status') + identity_table.add_column('Field', style='cyan') + identity_table.add_column('Value', style='green') + identity_table.add_row('Target', _api_target_label(resolved_api_url)) + identity_table.add_row('User', _stringify(me_payload.get('email') or me_payload.get('user_id'))) + identity_table.add_row('Admin', _stringify(me_payload.get('is_admin'))) + identity_table.add_row('Plan', _stringify(quota_payload.get('plan'))) + identity_table.add_row('Runs Remaining', _stringify(quota_payload.get('runs_remaining'))) + installed_count = sum(1 for item in installables_payload if item.get('installed')) + identity_table.add_row('Installables Enabled', str(installed_count)) + console.print(identity_table) diff --git a/cli/manual.py b/cli/manual.py new file mode 100644 index 0000000..b89b9aa --- /dev/null +++ b/cli/manual.py @@ -0,0 +1,562 @@ +from typing import Dict, Iterable, Sequence + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + + +_MANUAL: Dict[str, Dict[str, object]] = { + 'agos': { + 'summary': 'AGOS CLI for authentication, runtime operations, and API-backed platform management.', + 'examples': [ + 'agos login', + 'agos ask Hi', + 'agos ask "Summarize today\'s failed tasks"', + 'agos whoami', + 'agos agent list', + 'agos task list --status running', + 'agos workflow run wf_autoblogger_v1 --agent-id agent_123', + 'agos help agent', + 'agos man workflow run', + ], + 'notes': [ + 'Most API-backed commands honor `--api-url` and default to `http://localhost:2000`.', + 'Many read commands also accept `--json-output` for scripting.', + 'Run `agos help ` for focused usage or `agos man` for the full manual.', + ], + }, + 'login': { + 'summary': 'Authenticate the CLI with Armco IAM and cache tokens in `~/.agos/auth.json`.', + 'examples': [ + 'agos login', + 'agos login --issuer https://iam.armco.dev --client-id client_abc123', + 'agos login --port 8976 --timeout 240', + ], + }, + 'whoami': { + 'summary': 'Show the identity, tenant, scopes, and admin status for the current CLI session.', + 'examples': [ + 'agos whoami', + 'agos whoami --json-output', + 'agos whoami --api-url https://agos.armco.dev', + ], + }, + 'quota': { + 'summary': 'Inspect your current quota status or recent quota consumption history.', + 'examples': [ + 'agos quota', + 'agos quota --history', + 'agos quota --json-output', + ], + }, + 'logout': { + 'summary': 'Clear the locally cached Agos session from disk.', + 'examples': ['agos logout'], + }, + 'help': { + 'summary': 'Show focused help for a top-level command or nested action.', + 'examples': [ + 'agos help', + 'agos help agent', + 'agos help workflow run', + ], + }, + 'clear': { + 'summary': 'Clear the visible terminal screen for the current CLI session.', + 'examples': ['agos clear'], + }, + 'ask': { + 'summary': 'Send a natural-language prompt to Agos chat using the authenticated CLI session.', + 'examples': [ + 'agos ask Hi', + 'agos ask "Plan a launch checklist for AGOS"', + 'agos ask --json-output "Summarize current quota usage"', + ], + }, + 'man': { + 'summary': 'Show the expanded Agos manual with detailed actions and examples.', + 'examples': [ + 'agos man', + 'agos man task create', + 'agos man installable', + ], + }, + 'db': { + 'summary': 'Run local database bootstrap, seed, and status operations.', + 'examples': [ + 'agos db status --host localhost --database agos', + 'agos db bootstrap --database agos --user postgres', + ], + }, + 'db bootstrap': { + 'summary': 'Apply the Agos bootstrap schema to a PostgreSQL database.', + 'examples': [ + 'agos db bootstrap --database agos --user postgres', + ], + }, + 'db seed': { + 'summary': 'Seed the Agos database with local development data.', + 'examples': [ + 'agos db seed --database agos --user postgres', + ], + }, + 'db status': { + 'summary': 'Check database connectivity and basic table counts.', + 'examples': [ + 'agos db status --database agos --user postgres', + ], + }, + 'installable': { + 'summary': 'Inspect and manage installable Agos features for the current instance.', + 'examples': [ + 'agos installable list', + 'agos installable install integrations', + ], + }, + 'installable list': { + 'summary': 'List installable features and whether they are enabled.', + 'examples': [ + 'agos installable list', + 'agos installable list --json-output', + ], + }, + 'installable install': { + 'summary': 'Enable a named installable feature.', + 'examples': ['agos installable install integrations'], + }, + 'installable uninstall': { + 'summary': 'Disable a named installable feature.', + 'examples': ['agos installable uninstall integrations'], + }, + 'agent': { + 'summary': 'Create, inspect, run, and control Agos agents.', + 'examples': [ + 'agos agent list', + 'agos agent get agent_123', + 'agos agent run agent_123 --goal "Summarize today\'s issues"', + ], + }, + 'agent list': { + 'summary': 'List agents with optional filtering by page, status, or ephemeral flag.', + 'examples': [ + 'agos agent list', + 'agos agent list --status running', + 'agos agent list --page 2 --page-size 50', + ], + }, + 'agent get': { + 'summary': 'Fetch the full details for a single agent.', + 'examples': ['agos agent get agent_123'], + }, + 'agent create': { + 'summary': 'Create a new agent with model, capabilities, and optional JSON configuration.', + 'examples': [ + 'agos agent create --name "Ops Agent" --model gpt-4.1', + ( + 'agos agent create --name "RAG Agent" --model gpt-4.1 ' + '--capability search --config-json "{\"temperature\":0.2}"' + ), + ], + }, + 'agent start': { + 'summary': 'Resume a paused agent.', + 'examples': ['agos agent start agent_123'], + }, + 'agent stop': { + 'summary': 'Pause a running agent.', + 'examples': ['agos agent stop agent_123'], + }, + 'agent delete': { + 'summary': 'Delete an agent after interactive confirmation.', + 'examples': ['agos agent delete agent_123'], + }, + 'agent run': { + 'summary': 'Queue a goal-driven run for an agent.', + 'examples': [ + 'agos agent run agent_123 --goal "Write the daily operations brief"', + 'agos agent run agent_123 --goal "Triage open incidents" --max-reasoning-turns 6', + ], + }, + 'task': { + 'summary': 'List, create, inspect, cancel, and delete tasks.', + 'examples': [ + 'agos task list', + 'agos task get task_123', + 'agos task create --agent-id agent_123 --description "Run daily sync"', + ], + }, + 'task list': { + 'summary': 'List tasks with optional agent and status filters.', + 'examples': [ + 'agos task list', + 'agos task list --status failed', + 'agos task list --agent-id agent_123', + ], + }, + 'task get': { + 'summary': 'Fetch the full details for a single task.', + 'examples': ['agos task get task_123'], + }, + 'task create': { + 'summary': 'Create a task for an agent with structured input and retry controls.', + 'examples': [ + 'agos task create --agent-id agent_123 --description "Analyze churn risk"', + ( + 'agos task create --agent-id agent_123 --description ' + '"Import backlog" --input-json "{\"board_id\":\"b1\"}"' + ), + ], + }, + 'task cancel': { + 'summary': 'Cancel an in-flight task.', + 'examples': ['agos task cancel task_123'], + }, + 'task delete': { + 'summary': 'Delete a task after interactive confirmation.', + 'examples': ['agos task delete task_123'], + }, + 'workflow': { + 'summary': 'Inspect, create, run, and monitor workflows.', + 'examples': [ + 'agos workflow list', + 'agos workflow create --name "Daily Brief" --definition-file workflow.json', + 'agos workflow run wf_123 --agent-id agent_123', + ], + }, + 'workflow list': { + 'summary': 'List workflows with optional status and text search filters.', + 'examples': [ + 'agos workflow list', + 'agos workflow list --status active', + 'agos workflow list --search autoblogger', + ], + }, + 'workflow get': { + 'summary': 'Fetch the full details for a workflow.', + 'examples': ['agos workflow get wf_123'], + }, + 'workflow create': { + 'summary': 'Create a workflow from inline JSON or a definition file.', + 'examples': [ + 'agos workflow create --name "Daily Brief" --definition-file workflow.json', + 'agos workflow create --name "Quick Flow" --definition-json "{\"nodes\":[]}"', + ], + }, + 'workflow run': { + 'summary': 'Start a workflow execution with an agent and optional runtime parameters.', + 'examples': [ + 'agos workflow run wf_123 --agent-id agent_123', + 'agos workflow run wf_123 --agent-id agent_123 --parameters-json "{\"region\":\"us\"}"', + ], + }, + 'workflow status': { + 'summary': 'List recent executions for a workflow.', + 'examples': [ + 'agos workflow status wf_123', + 'agos workflow status wf_123 --limit 50', + ], + }, + 'plugin': { + 'summary': 'List, search, install, inspect, enable, disable, and uninstall plugins.', + 'examples': [ + 'agos plugin list', + 'agos plugin search slack', + 'agos plugin enable plugin_123', + ], + }, + 'plugin list': { + 'summary': 'List installed plugins with trust and verification filters.', + 'examples': [ + 'agos plugin list', + 'agos plugin list --verified-only', + 'agos plugin list --trust-level official', + ], + }, + 'plugin get': { + 'summary': 'Fetch the full details for a plugin.', + 'examples': ['agos plugin get plugin_123'], + }, + 'plugin search': { + 'summary': 'Search the marketplace plugin catalog.', + 'examples': [ + 'agos plugin search slack', + 'agos plugin search rag', + ], + }, + 'plugin install': { + 'summary': 'Install a plugin from manifest metadata and local manifest JSON.', + 'examples': [ + 'agos plugin install agos-chat-launcher --version 1.0.0 --author armco --manifest-file manifest.json', + ], + }, + 'plugin enable': { + 'summary': 'Enable a previously installed plugin.', + 'examples': ['agos plugin enable plugin_123'], + }, + 'plugin disable': { + 'summary': 'Disable an installed plugin.', + 'examples': ['agos plugin disable plugin_123'], + }, + 'plugin uninstall': { + 'summary': 'Uninstall a plugin after interactive confirmation.', + 'examples': ['agos plugin uninstall plugin_123'], + }, + 'policy': { + 'summary': 'List, create, and delete platform policies.', + 'examples': [ + 'agos policy list', + 'agos policy create --name "No Public Internet" --type network --conditions-json "{\"egress\":\"deny\"}"', + ], + }, + 'policy list': { + 'summary': 'List policies with optional type, agent, and enabled filters.', + 'examples': [ + 'agos policy list', + 'agos policy list --enabled-only', + 'agos policy list --policy-type network', + ], + }, + 'policy create': { + 'summary': 'Create a new policy with structured conditions.', + 'examples': [ + ( + 'agos policy create --name "Filesystem Guard" --type ' + 'filesystem --conditions-json "{\"paths\":[\"/tmp\"]}"' + ), + ], + }, + 'policy delete': { + 'summary': 'Delete a policy after interactive confirmation.', + 'examples': ['agos policy delete policy_123'], + }, + 'integration': { + 'summary': 'Inspect the integration catalog, providers, instances, and connectivity.', + 'examples': [ + 'agos integration catalog --search slack', + 'agos integration providers', + 'agos integration create --provider-id slack --name "Ops Slack"', + ], + }, + 'integration catalog': { + 'summary': 'List catalog entries for supported integrations.', + 'examples': [ + 'agos integration catalog', + 'agos integration catalog --search slack', + 'agos integration catalog --category communication', + ], + }, + 'integration providers': { + 'summary': 'List configured integration providers.', + 'examples': [ + 'agos integration providers', + 'agos integration providers --category communication', + ], + }, + 'integration instances': { + 'summary': 'List created integration instances.', + 'examples': [ + 'agos integration instances', + 'agos integration instances --category communication', + ], + }, + 'integration create': { + 'summary': 'Create a new integration instance with JSON config.', + 'examples': [ + ( + 'agos integration create --provider-id slack --name ' + '"Ops Slack" --config-json "{\"workspace\":\"armco\"}"' + ), + ], + }, + 'integration test': { + 'summary': 'Run a test against an integration instance.', + 'examples': ['agos integration test instance_123'], + }, + 'system': { + 'summary': 'Inspect high-level system health and local runtime views.', + 'examples': [ + 'agos system status', + 'agos system logs --lines 100', + ], + }, + 'system status': { + 'summary': 'Show a system summary using the authenticated API surface.', + 'examples': [ + 'agos system status', + 'agos system status --json-output', + ], + }, + 'system logs': { + 'summary': 'Show the legacy local log view.', + 'examples': ['agos system logs --lines 100'], + }, + 'system metrics': { + 'summary': 'Show the legacy local metrics view.', + 'examples': ['agos system metrics'], + }, +} + + +def _visible_commands(command: click.MultiCommand) -> Iterable[tuple[str, click.Command]]: + for name in command.list_commands(click.Context(command)): + if name.endswith('-stub') or name.endswith('-command'): + continue + yield name, command.get_command(click.Context(command), name) + + +def _find_command(root: click.MultiCommand, topic: str) -> tuple[str, click.Command]: + normalized = ' '.join(topic.strip().split()) + if normalized in {'', 'agos'}: + return 'agos', root + if normalized.startswith('agos '): + normalized = normalized[len('agos '):] + parts = normalized.split() + current: click.Command = root + path_parts = ['agos'] + for part in parts: + if not isinstance(current, click.MultiCommand): + raise click.ClickException(f'`{normalized}` is not a valid Agos command path.') + next_command = current.get_command(click.Context(current), part) + if next_command is None: + raise click.ClickException(f'Unknown Agos help topic: `{normalized}`.') + current = next_command + path_parts.append(part) + return ' '.join(path_parts), current + + +def _entry_for(path: str) -> Dict[str, object]: + normalized = path.replace('agos ', '').strip() + if path == 'agos': + return _MANUAL.get('agos', {}) + return _MANUAL.get(normalized, {}) + + +def _usage_for(path: str, command: click.Command) -> str: + ctx = click.Context(command, info_name=path) + return command.get_usage(ctx).strip() + + +def _examples_for(path: str) -> Sequence[str]: + return _entry_for(path).get('examples', []) # type: ignore[return-value] + + +def _notes_for(path: str) -> Sequence[str]: + return _entry_for(path).get('notes', []) # type: ignore[return-value] + + +def _summary_for(path: str, command: click.Command) -> str: + entry = _entry_for(path) + if entry.get('summary'): + return str(entry['summary']) + return command.help or command.short_help or 'No description available.' + + +def _render_subcommands(console: Console, path: str, command: click.Command) -> None: + if not isinstance(command, click.MultiCommand): + return + rows = list(_visible_commands(command)) + if not rows: + return + table = Table(title=f'{path} actions') + table.add_column('Action', style='cyan') + table.add_column('Description', style='green') + for name, subcommand in rows: + sub_path = f'{path} {name}' + table.add_row(sub_path, _summary_for(sub_path, subcommand)) + console.print(table) + + +def _render_params(console: Console, command: click.Command) -> None: + if not command.params: + return + table = Table(title='Arguments and Options') + table.add_column('Name', style='cyan') + table.add_column('Required', style='magenta') + table.add_column('Type', style='yellow') + table.add_column('Details', style='green') + for param in command.params: + if isinstance(param, click.Option): + name = ', '.join([*param.opts, *param.secondary_opts]) + details = param.help or '' + if param.default not in (None, (), []): + details = f'{details} Default: {param.default}'.strip() + table.add_row( + name, + 'yes' if param.required else 'no', + 'option', + details or '—', + ) + else: + table.add_row( + param.human_readable_name, + 'yes', + 'argument', + 'Positional argument.', + ) + console.print(table) + + +def _render_examples(console: Console, path: str) -> None: + examples = list(_examples_for(path)) + if not examples: + return + body = '\n'.join(f'$ {example}' for example in examples) + console.print(Panel.fit(body, title='Examples')) + + +def _render_notes(console: Console, path: str) -> None: + notes = list(_notes_for(path)) + if not notes: + return + body = '\n'.join(f'- {note}' for note in notes) + console.print(Panel.fit(body, title='Notes')) + + +def _render_topic(console: Console, root: click.MultiCommand, topic: str) -> None: + path, command = _find_command(root, topic) + console.rule(f'[bold blue]{path}[/bold blue]') + console.print(Panel.fit(_summary_for(path, command), title='Summary')) + console.print(Panel.fit(_usage_for(path, command), title='Usage')) + _render_subcommands(console, path, command) + _render_params(console, command) + _render_examples(console, path) + _render_notes(console, path) + + +def _render_root_help(console: Console, root: click.MultiCommand) -> None: + console.print(Panel.fit(_summary_for('agos', root), title='Agos Help')) + _render_subcommands(console, 'agos', root) + _render_examples(console, 'agos') + _render_notes(console, 'agos') + + +def _all_topics(root: click.MultiCommand) -> Sequence[str]: + topics = ['agos'] + for name, command in _visible_commands(root): + path = f'agos {name}' + topics.append(path) + if isinstance(command, click.MultiCommand): + for sub_name, _ in _visible_commands(command): + topics.append(f'{path} {sub_name}') + return topics + + +def install_manual_commands(cli: click.MultiCommand, console: Console) -> None: + @cli.command(name='help') + @click.argument('topic', nargs=-1) + def help_command(topic: Sequence[str]) -> None: + if not topic: + _render_root_help(console, cli) + return + _render_topic(console, cli, ' '.join(topic)) + + @cli.command(name='man') + @click.argument('topic', nargs=-1) + def man_command(topic: Sequence[str]) -> None: + if topic: + _render_topic(console, cli, ' '.join(topic)) + return + console.print(Panel.fit(_summary_for('agos', cli), title='Agos Manual')) + for manual_topic in _all_topics(cli): + _render_topic(console, cli, manual_topic) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6fe994e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "agos-cli" +version = "0.1.0" +description = "AGOS command line interface" +requires-python = ">=3.11" +dependencies = [ + "click==8.1.7", + "rich==13.7.0", + "requests==2.32.3", + "python-dotenv==1.0.1", +] + +[project.scripts] +agos = "cli.agos:cli" + +[tool.setuptools] +packages = ["cli"]