diff --git a/cli/agos.py b/cli/agos.py index 792dd1b..092d935 100644 --- a/cli/agos.py +++ b/cli/agos.py @@ -2,6 +2,7 @@ """AGOS CLI - Main entry point.""" import click +import importlib from rich.console import Console from rich.table import Table import sys @@ -22,8 +23,13 @@ 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 +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +install_api_commands = importlib.import_module( + 'cli.command_surface' +).install_api_commands +install_manual_commands = importlib.import_module( + 'cli.manual' +).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 @@ -56,6 +62,53 @@ for _env_dir in _ENV_CANDIDATE_DIRS: console = Console() _AUTH_DIR = Path.home() / '.agos' _AUTH_FILE = _AUTH_DIR / 'auth.json' +_CONFIG_FILE = _AUTH_DIR / 'config.json' + +# Keys the CLI recognizes and their descriptions +_CONFIG_SCHEMA: dict[str, dict] = { + 'AGOS_CLI_IAM_ISSUER': { + 'description': 'Armco IAM issuer URL (e.g. https://iam.armco.dev)', + 'required': True, + 'env_aliases': ['VITE_IAM_ISSUER', 'IAM_ISSUER'], + }, + 'AGOS_CLI_IAM_CLIENT_ID': { + 'description': 'OAuth client ID for the native/CLI app', + 'required': True, + 'env_aliases': ['VITE_IAM_DESKTOP_CLIENT_ID', 'VITE_IAM_CLIENT_ID'], + }, + 'AGOS_CLI_API_URL': { + 'description': 'AGOS API base URL (default: http://localhost:2000)', + 'required': False, + 'env_aliases': ['AGOS_API_URL', 'VITE_API_URL'], + }, +} + + +def _load_config() -> dict: + if _CONFIG_FILE.exists(): + try: + return json.loads(_CONFIG_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + return {} + + +def _save_config(cfg: dict) -> None: + _AUTH_DIR.mkdir(parents=True, exist_ok=True) + _CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding='utf-8') + os.chmod(_CONFIG_FILE, 0o600) + + +def _config_effective_value(key: str) -> str | None: + """Return the first set value for a config key across stored config + env aliases.""" + cfg = _load_config() + if key in cfg and cfg[key]: + return cfg[key] + for alias in _CONFIG_SCHEMA.get(key, {}).get('env_aliases', []): + val = os.environ.get(alias, '').strip() + if val: + return val + return None def _utc_now_iso() -> str: @@ -281,16 +334,22 @@ def cli(): 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_issuer = issuer or _config_effective_value('AGOS_CLI_IAM_ISSUER') or _first_env('VITE_IAM_ISSUER', 'IAM_ISSUER') + resolved_client_id = client_id or _config_effective_value('AGOS_CLI_IAM_CLIENT_ID') or _first_env('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.') + raise click.ClickException( + 'Missing IAM issuer. Run `agos config` to set it up, ' + 'or 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.') + raise click.ClickException( + 'Missing CLI IAM client ID. Run `agos config` to set it up, ' + 'or set AGOS_CLI_IAM_CLIENT_ID / VITE_IAM_DESKTOP_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( @@ -410,6 +469,128 @@ def login(issuer, client_id, host, port, timeout_seconds, scopes): raise click.ClickException(str(exc)) from exc +@cli.command(name='config') +@click.argument('assignment', nargs=-1, metavar='[KEY=VALUE ...]') +@click.option('--list', 'list_all', is_flag=True, default=False, help='Show current stored config.') +@click.option('--unset', default=None, metavar='KEY', help='Remove a stored config key.') +def config_command(assignment, list_all, unset): + """Configure CLI settings (IAM issuer, client ID, API URL, etc.). + + Run without arguments for interactive setup. + Pass KEY=VALUE pairs to set specific values. + Use --list to view current config or --unset KEY to remove a value. + + \b + Examples: + agos config + agos config AGOS_CLI_IAM_ISSUER=https://iam.armco.dev + agos config AGOS_CLI_IAM_CLIENT_ID=client_abc123 + agos config --list + agos config --unset AGOS_CLI_API_URL + """ + cfg = _load_config() + + # --unset mode + if unset: + key = unset.strip() + if key in cfg: + del cfg[key] + _save_config(cfg) + console.print(f'[yellow]Unset[/yellow] {key}') + else: + console.print(f'[dim]{key} was not set in config.[/dim]') + return + + # --list mode + if list_all: + if not cfg and not _CONFIG_SCHEMA: + console.print('[dim]No config stored yet. Run `agos config` to set up.[/dim]') + return + table = Table(title='Agos CLI Config (~/.agos/config.json)') + table.add_column('Key', style='cyan') + table.add_column('Stored Value', style='green') + table.add_column('Effective Value', style='yellow') + table.add_column('Source', style='dim') + for key, meta in _CONFIG_SCHEMA.items(): + stored = cfg.get(key, '') + effective = _config_effective_value(key) + source = 'stored' if stored else ('env/alias' if effective else '—') + table.add_row(key, stored or '—', effective or '—', source) + # show any extra stored keys not in schema + for key, val in cfg.items(): + if key not in _CONFIG_SCHEMA: + table.add_row(key, val, val, 'stored (custom)') + console.print(table) + return + + # KEY=VALUE mode + if assignment: + updated = [] + for pair in assignment: + if '=' not in pair: + raise click.ClickException( + f'Invalid format: `{pair}`. Use KEY=VALUE (e.g. AGOS_CLI_IAM_ISSUER=https://iam.armco.dev)' + ) + key, _, value = pair.partition('=') + key = key.strip() + value = value.strip() + cfg[key] = value + updated.append(key) + _save_config(cfg) + for key in updated: + console.print(f'[green]Set[/green] {key} = {cfg[key]}') + return + + # Interactive mode — check required keys and prompt for any that are missing + console.print('[bold blue]Agos CLI Configuration Setup[/bold blue]') + console.print( + f'[dim]Settings are saved to {_CONFIG_FILE}[/dim]\n' + '[dim]Press Enter to keep an existing value.[/dim]\n' + ) + changed = False + for key, meta in _CONFIG_SCHEMA.items(): + effective = _config_effective_value(key) + stored = cfg.get(key, '') + required_label = '[red]*required[/red]' if meta['required'] else '[dim]optional[/dim]' + prompt_default = stored or '' + # Show current effective source if not stored directly + if effective and not stored: + console.print( + f'[dim] {key} is currently satisfied by an environment variable/alias: {effective}[/dim]' + ) + display_default = f' [dim](current: {stored})[/dim]' if stored else '' + console.print(f'\n[cyan]{key}[/cyan] {required_label}{display_default}') + console.print(f' [dim]{meta["description"]}[/dim]') + new_value = click.prompt(f' > {key}', default=prompt_default, show_default=False) + new_value = new_value.strip() + if new_value and new_value != stored: + cfg[key] = new_value + changed = True + elif not new_value and stored: + # User cleared the value + del cfg[key] + changed = True + + if changed: + _save_config(cfg) + console.print('\n[bold green]✓ Config saved.[/bold green]') + else: + console.print('\n[dim]No changes.[/dim]') + + # Final summary + console.print() + all_ok = all( + _config_effective_value(k) + for k, m in _CONFIG_SCHEMA.items() + if m['required'] + ) + if all_ok: + console.print('[bold green]✓ All required settings are configured. Run `agos login` to authenticate.[/bold green]') + else: + missing = [k for k, m in _CONFIG_SCHEMA.items() if m['required'] and not _config_effective_value(k)] + console.print(f'[yellow]⚠ Still missing required keys: {", ".join(missing)}[/yellow]') + + @cli.group() def db(): """Database management commands""" diff --git a/cli/manual.py b/cli/manual.py index b89b9aa..a4b95e5 100644 --- a/cli/manual.py +++ b/cli/manual.py @@ -10,10 +10,11 @@ _MANUAL: Dict[str, Dict[str, object]] = { 'agos': { 'summary': 'AGOS CLI for authentication, runtime operations, and API-backed platform management.', 'examples': [ + 'agos config', 'agos login', + 'agos whoami', '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', @@ -54,6 +55,25 @@ _MANUAL: Dict[str, Dict[str, object]] = { 'summary': 'Clear the locally cached Agos session from disk.', 'examples': ['agos logout'], }, + 'config': { + 'summary': ( + 'Configure CLI settings (IAM issuer, client ID, API URL) stored in ~/.agos/config.json. ' + 'Run without arguments for an interactive guided setup. ' + 'Pass KEY=VALUE pairs to set individual keys non-interactively.' + ), + 'examples': [ + 'agos config', + 'agos config AGOS_CLI_IAM_ISSUER=https://iam.armco.dev', + 'agos config AGOS_CLI_IAM_CLIENT_ID=client_abc123', + 'agos config --list', + 'agos config --unset AGOS_CLI_API_URL', + ], + 'notes': [ + 'Config is stored at ~/.agos/config.json (mode 0600).', + 'Values set here override .env files but are overridden by explicit --flags.', + 'After configuring, run `agos login` to authenticate.', + ], + }, 'help': { 'summary': 'Show focused help for a top-level command or nested action.', 'examples': [