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:
193
cli/agos.py
193
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"""
|
||||
|
||||
@@ -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': [
|
||||
|
||||
Reference in New Issue
Block a user