feat: add agos config command for persistent CLI settings

Adds a new `agos config` command that stores CLI settings in
~/.agos/config.json (mode 0600), alongside auth.json.

Supported modes:
  agos config                    # interactive guided setup
  agos config KEY=VALUE ...      # non-interactive key/value set
  agos config --list             # show stored + effective values (incl. env aliases)
  agos config --unset KEY        # remove a stored key

Recognized keys:
  AGOS_CLI_IAM_ISSUER    — IAM issuer URL (required for login)
  AGOS_CLI_IAM_CLIENT_ID — OAuth native app client ID (required for login)
  AGOS_CLI_API_URL       — API base URL (optional, defaults to localhost:2000)

`agos login` now reads from stored config, so a one-time `agos config`
removes the need for --issuer / --client-id flags on every login.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-21 17:55:48 +05:30
parent 5484d1f5d1
commit 6a7fc4e5ef
2 changed files with 208 additions and 7 deletions

View File

@@ -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"""

View File

@@ -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': [