From a89c6c7e85479bb116eda3bfb6f657b888f186db Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 7 Jul 2026 09:00:30 -0700 Subject: [PATCH 1/2] feat: add login command, alphabetize help, remove workflows Beta label Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/cli.py | 183 ++++++++++++++++++++++----- cortexapps_cli/commands/workflows.py | 6 +- 2 files changed, 152 insertions(+), 37 deletions(-) diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index 5664c5c..cfdba3c 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -9,6 +9,8 @@ import tomllib import configparser import logging +import webbrowser +import requests from cortexapps_cli.cortex_client import CortexClient @@ -43,43 +45,23 @@ import cortexapps_cli.commands.users as users import cortexapps_cli.commands.workflows as workflows -app = typer.Typer( +class _SortedTyper(typer.Typer): + """Typer subclass that sorts all commands alphabetically in --help output.""" + def __call__(self, *args, **kwargs): + import typer.main as _tm + click_app = _tm.get_command(self) + click_app.commands = dict(sorted(click_app.commands.items())) + return click_app(*args, **kwargs) + +app = _SortedTyper( no_args_is_help=True, rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]} ) -# add subcommands -app.add_typer(api_keys.app, name="api-keys") -app.add_typer(audit_logs.app, name="audit-logs") -app.add_typer(backup.app, name="backup") -app.add_typer(catalog.app, name="catalog") -app.add_typer(custom_data.app, name="custom-data") -app.add_typer(custom_events.app, name="custom-events") -app.add_typer(custom_metrics.app, name="custom-metrics") -app.add_typer(dependencies.app, name="dependencies") -app.add_typer(deploys.app, name="deploys") -app.add_typer(discovery_audit.app, name="discovery-audit") -app.add_typer(docs.app, name="docs") -app.add_typer(entity_types.app, name="entity-types") -app.add_typer(entity_relationship_types.app, name="entity-relationship-types") -app.add_typer(entity_relationships.app, name="entity-relationships") -app.add_typer(gitops_logs.app, name="gitops-logs") -app.add_typer(groups.app, name="groups") -app.add_typer(initiatives.app, name="initiatives") -app.add_typer(integrations.app, name="integrations") -app.add_typer(ip_allowlist.app, name="ip-allowlist") -app.add_typer(on_call.app, name="on-call") -app.add_typer(packages.app, name="packages") -app.add_typer(plugins.app, name="plugins") -app.add_typer(queries.app, name="queries") -app.add_typer(rest.app, name="rest") -app.add_typer(scim.app, name="scim") -app.add_typer(scorecards.app, name="scorecards") -app.add_typer(secrets.app, name="secrets") -app.add_typer(teams.app, name="teams") -app.add_typer(users.app, name="users") -app.add_typer(workflows.app, name="workflows") +# Commands and subcommands are registered below in alphabetical order. +# Direct commands (login, version) are registered via app.command() calls +# interleaved with add_typer() calls so they appear alphabetically in --help. # global options @app.callback() @@ -95,6 +77,14 @@ def global_callback( if not ctx.obj: ctx.obj = {} + # Store config file path and tenant for use by the login command + ctx.obj["config_file"] = config_file + ctx.obj["tenant"] = tenant + + # login command handles its own auth setup + if ctx.invoked_subcommand == "login": + return + numeric_level = getattr(logging, log_level.upper(), None) if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level: {log_level}") @@ -140,11 +130,102 @@ def global_callback( ctx.obj["client"] = CortexClient(api_key, tenant, numeric_level, url, rate_limit) -@app.command() +DEFAULT_API_URL = "https://api.getcortexapp.com" +DEFAULT_UI_URL = "https://app.getcortexapp.com" +DEFAULT_UI_KEYS_PATH = "/admin/settings/personal-access-tokens" + +def login( + ctx: typer.Context, + open_browser: bool = typer.Option(True, "--open-browser/--no-open-browser", help="Open browser to Cortex login page"), + force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing tenant config without prompting"), +): + """ + Log in to Cortex and save credentials to the config file + """ + config_file = ctx.obj.get("config_file") + + # Prompt for workspace name — this is both the Cortex workspace slug and the local config alias + default_tenant = ctx.obj.get("tenant", "default") + tenant = typer.prompt("Workspace name", default=default_tenant).strip() + + # Prompt for base URL + url = typer.prompt("Base URL", default=DEFAULT_API_URL).strip('"\' /') + + # Check existing config for this tenant + config = configparser.ConfigParser() + if os.path.isfile(config_file): + config.read(config_file) + if tenant in config and not force: + if not typer.confirm(f"Workspace '{tenant}' already exists in config. Overwrite?"): + raise typer.Abort() + + # Derive UI URL, prefilling the workspace name on the login page + if url == DEFAULT_API_URL: + ui_base = DEFAULT_UI_URL + else: + ui_base = url.replace("://api.", "://app.") + + pat_url = f"{ui_base}{DEFAULT_UI_KEYS_PATH}?tenantCode={tenant}" + + if open_browser: + typer.echo(f"\nOpening browser to the Personal Access Tokens page for workspace '{tenant}'.") + typer.echo(f"Create or copy a personal access token, then come back here and paste it below.\n") + webbrowser.open(pat_url) + else: + typer.echo(f"\nGo to {pat_url} to create or copy a personal access token, then paste it below.\n") + + # Prompt for API key (hidden input) + api_key = typer.prompt("API key", hide_input=True).strip('"\' ') + + # Validate the key with a lightweight API call + typer.echo("Validating API key...", nl=False) + try: + resp = requests.get( + f"{url}/api/v1/catalog", + headers={ + "Authorization": f"Bearer {api_key}", + "User-Agent": "cortexapps-cli/login", + }, + params={"pageSize": 1}, + timeout=10, + ) + if resp.status_code == 401: + typer.echo(" invalid.") + typer.echo("Error: API key was not accepted (HTTP 401). Please check the key and try again.") + raise typer.Exit(1) + elif not resp.ok: + typer.echo(f" failed (HTTP {resp.status_code}).") + typer.echo(f"Error: Unexpected response from {url}. Please check the URL and try again.") + raise typer.Exit(1) + else: + typer.echo(" OK.") + except requests.exceptions.ConnectionError: + typer.echo(" connection failed.") + typer.echo(f"Error: Could not connect to {url}. Please check the URL and try again.") + raise typer.Exit(1) + except requests.exceptions.Timeout: + typer.echo(" timed out.") + typer.echo(f"Error: Request to {url} timed out. Please check the URL and try again.") + raise typer.Exit(1) + + # Write / update config + os.makedirs(os.path.dirname(config_file), exist_ok=True) + config[tenant] = {"api_key": api_key} + if url != DEFAULT_API_URL: + config[tenant]["base_url"] = url + + with open(config_file, "w") as f: + config.write(f) + + tenant_flag = f" -t {tenant}" if tenant != "default" else "" + typer.echo(f"\nCredentials saved to {config_file}") + typer.echo(f"You can now run: cortex{tenant_flag} catalog list") + + def version(): """ - Show the version and exit. + Show the version and exit """ try: with open("pyproject.toml", "rb") as f: @@ -154,5 +235,39 @@ def version(): version = importlib.metadata.version('cortexapps_cli') print(version) +# Register all commands alphabetically so they appear in order in --help +app.add_typer(api_keys.app, name="api-keys") +app.add_typer(audit_logs.app, name="audit-logs") +app.add_typer(backup.app, name="backup") +app.add_typer(catalog.app, name="catalog") +app.add_typer(custom_data.app, name="custom-data") +app.add_typer(custom_events.app, name="custom-events") +app.add_typer(custom_metrics.app, name="custom-metrics") +app.add_typer(dependencies.app, name="dependencies") +app.add_typer(deploys.app, name="deploys") +app.add_typer(discovery_audit.app, name="discovery-audit") +app.add_typer(docs.app, name="docs") +app.add_typer(entity_relationship_types.app, name="entity-relationship-types") +app.add_typer(entity_relationships.app, name="entity-relationships") +app.add_typer(entity_types.app, name="entity-types") +app.add_typer(gitops_logs.app, name="gitops-logs") +app.add_typer(groups.app, name="groups") +app.add_typer(initiatives.app, name="initiatives") +app.add_typer(integrations.app, name="integrations") +app.add_typer(ip_allowlist.app, name="ip-allowlist") +app.command()(login) +app.add_typer(on_call.app, name="on-call") +app.add_typer(packages.app, name="packages") +app.add_typer(plugins.app, name="plugins") +app.add_typer(queries.app, name="queries") +app.add_typer(rest.app, name="rest") +app.add_typer(scim.app, name="scim") +app.add_typer(scorecards.app, name="scorecards") +app.add_typer(secrets.app, name="secrets") +app.add_typer(teams.app, name="teams") +app.add_typer(users.app, name="users") +app.command()(version) +app.add_typer(workflows.app, name="workflows") + if __name__ == "__main__": app() diff --git a/cortexapps_cli/commands/workflows.py b/cortexapps_cli/commands/workflows.py index 29669c4..c1ff46a 100644 --- a/cortexapps_cli/commands/workflows.py +++ b/cortexapps_cli/commands/workflows.py @@ -7,7 +7,7 @@ import yaml app = typer.Typer( - help="Workflows commands (Beta)", + help="Workflows commands", no_args_is_help=True ) @@ -156,7 +156,7 @@ def run( timeout: int = typer.Option(300, "--timeout", help="Max seconds to wait when --wait is used"), ): """ - (Beta) Run a workflow. API key must have the Run workflows permission. + Run a workflow. API key must have the Run workflows permission. The workflow must have isRunnableViaApi set to true. """ import time as time_module @@ -214,7 +214,7 @@ def get_run( run_id: str = typer.Option(..., "--run-id", "-r", help="The run ID"), ): """ - (Beta) Get details of a workflow run. API key must have the View workflow runs permission. + Get details of a workflow run. API key must have the View workflow runs permission. """ client = ctx.obj["client"] r = client.get(f"api/v1/workflows/{tag}/runs/{run_id}") From 0ede307c8173193cb0e07747ac4f3438a320c954 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 7 Jul 2026 09:35:13 -0700 Subject: [PATCH 2/2] chore: add --auto flag to gh pr merge in test-pr workflow Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml index 7dd933a..053f5f1 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test-pr.yml @@ -83,4 +83,4 @@ jobs: - name: Auto-merge PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr merge ${{ github.event.pull_request.number }} --merge --repo ${{ github.repository }} + run: gh pr merge ${{ github.event.pull_request.number }} --merge --auto --repo ${{ github.repository }}