diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index 634c9e8c..9b37cdc8 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -6,7 +6,6 @@ import os import sys import importlib.metadata -import tomllib import configparser import logging import webbrowser @@ -19,6 +18,7 @@ import cortexapps_cli.commands.audit_logs as audit_logs import cortexapps_cli.commands.backup as backup import cortexapps_cli.commands.catalog as catalog +import cortexapps_cli.commands.catalogs as catalogs import cortexapps_cli.commands.custom_data as custom_data import cortexapps_cli.commands.custom_events as custom_events import cortexapps_cli.commands.custom_metrics as custom_metrics @@ -243,12 +243,9 @@ def version(): """ Show the version and exit """ - try: - with open("pyproject.toml", "rb") as f: - pyproject = tomllib.load(f) - version = pyproject["tool"]["poetry"]["version"] - except Exception as e: - version = importlib.metadata.version('cortexapps_cli') + version = importlib.metadata.version('cortexapps_cli') + if version == "0.0.0": + version = "0.0.0 (dev)" print(version) # Register all commands alphabetically so they appear in order in --help @@ -257,6 +254,7 @@ def version(): 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(catalogs.app, name="catalogs") 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") diff --git a/cortexapps_cli/commands/backup.py b/cortexapps_cli/commands/backup.py index a36d0f43..c7a22597 100644 --- a/cortexapps_cli/commands/backup.py +++ b/cortexapps_cli/commands/backup.py @@ -17,6 +17,7 @@ import cortexapps_cli.commands.scorecards as scorecards import cortexapps_cli.commands.catalog as catalog +import cortexapps_cli.commands.catalogs as catalogs import cortexapps_cli.commands.entity_types as entity_types import cortexapps_cli.commands.entity_relationship_types as entity_relationship_types import cortexapps_cli.commands.entity_relationships as entity_relationships @@ -206,16 +207,30 @@ def _export_workflows(ctx, directory): except Exception as e: print(f"Failed to export workflow {tag}: {e}") -backupTypes = { +def _export_catalog_pages(ctx, directory): + directory = _directory_name(directory, "catalogs") + + data = catalogs.list(ctx, _print=False) + pages = sorted(data.get("catalogPages", []), key=lambda x: x["slug"]) + + for page in pages: + slug = page["slug"] + try: + _file_name(directory, slug, json.dumps(page, indent=2), "json") + except Exception as e: + print(f"Failed to export catalog page {slug}: {e}") + +backupTypes = sorted([ "catalog", - "entity-types", + "catalogs", "entity-relationship-types", "entity-relationships", + "entity-types", "ip-allowlist", "plugins", "scorecards", - "workflows" -} + "workflows", +]) backupString = ','.join(backupTypes) def _parse_export_types(value: str) -> List[str]: @@ -257,6 +272,7 @@ def export( Exports the following objects: - catalog + - catalogs - entity-types - entity-relationship-types - entity-relationships @@ -310,6 +326,8 @@ def export( _export_scorecards(ctx, directory) if "workflows" in export_types: _export_workflows(ctx, directory) + if "catalogs" in export_types: + _export_catalog_pages(ctx, directory) print("\nExport complete!") print("Contents available in " + directory) @@ -744,6 +762,44 @@ def import_scorecard_file(file_info): return ("scorecards", len(results) - failed_count, [(fp, et, em) for fn, fp, et, em in results if et]) +def _import_catalog_pages(ctx, directory): + results = [] + failed_count = 0 + if os.path.isdir(directory): + print("Processing: " + directory) + files = [(filename, os.path.join(directory, filename)) + for filename in sorted(os.listdir(directory)) + if os.path.isfile(os.path.join(directory, filename))] + + def import_catalog_page_file(file_info): + filename, file_path = file_info + try: + with open(file_path) as f: + catalogs.create(ctx, file_input=f, _print=False) + return (filename, file_path, None, None) + except typer.Exit as e: + return (filename, file_path, "HTTP", "Validation or HTTP error") + except Exception as e: + return (filename, file_path, type(e).__name__, str(e)) + + # Import all files in parallel + with ThreadPoolExecutor(max_workers=10) as executor: + futures = {executor.submit(import_catalog_page_file, file_info): file_info[0] for file_info in files} + results = [] + for future in as_completed(futures): + results.append(future.result()) + + # Print results in alphabetical order + failed_count = 0 + for filename, file_path, error_type, error_msg in sorted(results, key=lambda x: x[0]): + if error_type: + print(f" Failed to import {filename}: {error_type} - {error_msg}") + failed_count += 1 + else: + print(f" Importing: {filename}") + + return ("catalogs", len(results) - failed_count, [(fp, et, em) for fn, fp, et, em in results if et]) + def _import_workflows(ctx, directory): results = [] failed_count = 0 @@ -805,6 +861,7 @@ def import_tenant( all_stats.append(_import_plugins(ctx, directory + "/plugins")) all_stats.append(_import_scorecards(ctx, directory + "/scorecards")) all_stats.append(_import_workflows(ctx, directory + "/workflows")) + all_stats.append(_import_catalog_pages(ctx, directory + "/catalogs")) # Print summary print("\n" + "="*80) @@ -861,6 +918,8 @@ def import_tenant( print(f"cortex scorecards create -f \"{file_path}\"") elif import_type == "workflows": print(f"cortex workflows create -f \"{file_path}\"") + elif import_type == "catalogs": + print(f"cortex catalogs create -f \"{file_path}\"") # Exit with non-zero code if any imports failed if total_failed > 0: diff --git a/cortexapps_cli/commands/catalogs.py b/cortexapps_cli/commands/catalogs.py new file mode 100644 index 00000000..61f45b45 --- /dev/null +++ b/cortexapps_cli/commands/catalogs.py @@ -0,0 +1,124 @@ +from cortexapps_cli.command_options import CommandOptions +from cortexapps_cli.command_options import ListCommandOptions +from cortexapps_cli.utils import print_output_with_context, print_output +from typing_extensions import Annotated +import json +import typer +import yaml + +app = typer.Typer( + help="Catalog page commands", + no_args_is_help=True +) + +def _read_definition(file_input): + """Parse a catalog page definition from a JSON or YAML file into a dict. + + The definition is always sent to the API as JSON, so a YAML file is parsed + and re-serialized rather than posted verbatim. + """ + content = file_input.read() + try: + return json.loads(content) + except json.JSONDecodeError: + pass + try: + return yaml.safe_load(content) + except yaml.YAMLError: + raise typer.BadParameter("Input file is neither valid JSON nor YAML.") + +@app.command() +def list( + ctx: typer.Context, + _print: CommandOptions._print = True, + page: ListCommandOptions.page = None, + page_size: ListCommandOptions.page_size = 250, + table_output: ListCommandOptions.table_output = False, + csv_output: ListCommandOptions.csv_output = False, + columns: ListCommandOptions.columns = [], + no_headers: ListCommandOptions.no_headers = False, + filters: ListCommandOptions.filters = [], + sort: ListCommandOptions.sort = [], +): + """ + List catalog pages. + """ + + client = ctx.obj["client"] + + params = { + "page": page, + "pageSize": page_size + } + + if (table_output or csv_output) and not ctx.params.get('columns'): + ctx.params['columns'] = [ + "Name=name", + "Slug=slug", + "Type=type", + "Description=description", + ] + + # remove any params that are None + params = {k: v for k, v in params.items() if v is not None} + + if page is None: + # if page is not specified, we want to fetch all pages + r = client.fetch("api/v1/catalog-pages", params=params) + else: + # if page is specified, we want to fetch only that page + r = client.get("api/v1/catalog-pages", params=params) + + if _print: + print_output_with_context(ctx, r) + else: + return(r) + +@app.command() +def get( + ctx: typer.Context, + slug: str = typer.Option(..., "--slug", "-s", help="The slug of the catalog page"), + _print: CommandOptions._print = True, +): + """ + Retrieve a catalog page by slug. + """ + + client = ctx.obj["client"] + + r = client.get("api/v1/catalog-pages/" + slug) + + if _print: + print_output_with_context(ctx, r) + else: + return(r) + +@app.command() +def create( + ctx: typer.Context, + file_input: Annotated[typer.FileText, typer.Option(..., "--file", "-f", help="File containing the catalog page definition (JSON or YAML); can be passed as stdin with -, example: -f-")], + _print: CommandOptions._print = True, +): + """ + Create a catalog page, or replace the existing one with the same slug. API key must have the Edit Catalogs permission. + """ + + client = ctx.obj["client"] + + data = _read_definition(file_input) + r = client.post("api/v1/catalog-pages", data=data) + if _print: + print_output(r) + +@app.command() +def delete( + ctx: typer.Context, + slug: str = typer.Option(..., "--slug", "-s", help="The slug of the catalog page"), +): + """ + Delete a catalog page by slug. API key must have the Edit Catalogs permission. + """ + + client = ctx.obj["client"] + + client.delete("api/v1/catalog-pages/" + slug) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 1206951b..e4830754 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -334,7 +334,11 @@ def _read_resource_tag(content: str, kind: str) -> str | None: data = yaml.safe_load(content) if not isinstance(data, dict): data = json.loads(content) - return data.get("type") if kind == "entity-types" else data.get("tag") + if kind == "entity-types": + return data.get("type") + if kind == "catalogs": + return data.get("slug") + return data.get("tag") except Exception: return None @@ -348,6 +352,7 @@ def _collect_solution_resources(path: Path) -> dict[str, list[str]]: "scorecards": [], "workflows": [], "plugins": [], + "catalogs": [], } for kind in resources: subdir = path / kind @@ -404,7 +409,7 @@ def _run_uninstall(client, path: Path, yes: bool) -> None: return typer.echo("\nThis will remove the following entities:") - for kind in ("workflows", "scorecards", "plugins", "catalog", "entity-relationship-types", "entity-types"): + for kind in ("catalogs", "workflows", "scorecards", "plugins", "catalog", "entity-relationship-types", "entity-types"): count = len(resources[kind]) if count: typer.echo(f" {kind}: {count}") @@ -418,6 +423,7 @@ def _run_uninstall(client, path: Path, yes: bool) -> None: # Delete in reverse import order steps = [ + ("catalogs", lambda t: f"api/v1/catalog-pages/{t}"), ("workflows", lambda t: f"api/v1/workflows/{t}"), ("scorecards", lambda t: f"api/v1/scorecards/{t}"), ("plugins", lambda t: f"api/v1/plugins/{t}"), diff --git a/cortexapps_cli/solutions/ai-agents/README.md b/cortexapps_cli/solutions/ai-agents/README.md index c235bc07..ede9057c 100644 --- a/cortexapps_cli/solutions/ai-agents/README.md +++ b/cortexapps_cli/solutions/ai-agents/README.md @@ -48,6 +48,10 @@ cortex solutions install -s ai-agents ## After Installing +**AI Agents catalog** + +AI agents are available in a catalog named [AI Agents](https://app.getcortexapp.com/admin/catalogs/ai-agents). + **Replace the sample entities with your real agents** The nine sample entities use `platform-engineering` as a placeholder team and `owner@example.com` as a placeholder business owner. For each real agent: @@ -112,14 +116,3 @@ x-cortex-git: **Reaching Gold** The Gold rule checks that the agent has been verified within the last 90 days (`verifications().lastVerifiedAt() != null and verifications().lastVerifiedAt().fromNow() > duration("P-90D")`). Verify entities via the Cortex UI or API — none of the sample entities start at Gold by design. Wire a quarterly reminder or a GitHub Action on your agent directories to prompt re-verification when files change. - -**Create an AI Agents Catalog in the UI** - -Cortex Catalogs (the nav-level groupings) are UI-only today — no API or CLI support yet: - -1. Go to [Catalogs](https://app.getcortexapp.com/admin/catalogs) → **New Catalog** -2. Entity type: `ai-agent` -3. Name it **AI Agents** - -> Catalog creation will be automated once catalog API support is added to the CLI. - diff --git a/cortexapps_cli/solutions/ai-agents/catalogs/ai-agents.json b/cortexapps_cli/solutions/ai-agents/catalogs/ai-agents.json new file mode 100644 index 00000000..4c578823 --- /dev/null +++ b/cortexapps_cli/solutions/ai-agents/catalogs/ai-agents.json @@ -0,0 +1,11 @@ +{ + "name": "AI Agents", + "slug": "ai-agents", + "iconTag": "list", + "isDraft": false, + "filter": { + "types": { + "include": ["ai-agent"] + } + } +} diff --git a/cortexapps_cli/solutions/ai-skills/README.md b/cortexapps_cli/solutions/ai-skills/README.md index 8f40f593..b5eb769c 100644 --- a/cortexapps_cli/solutions/ai-skills/README.md +++ b/cortexapps_cli/solutions/ai-skills/README.md @@ -55,6 +55,10 @@ cortex solutions install -s ai-skills ## After Installing +**AI Skills catalog** + +AI plugins and skills are available in a catalog named [AI Skills](https://app.getcortexapp.com/admin/catalogs/ai-skills). + **Populate quality data for real skills** Three of the scorecard's rules — concise (`lineCount`), description length (`descriptionCharCount`, named to avoid confusion with the entity's own `description` field), and shallow references (`hasDeepReferences`) — read from Cortex custom metadata. `cortex ai-skills sync` pushes these via the Custom Data API. For each real skill you ingest: @@ -72,14 +76,3 @@ The sample entities above don't have these set, so they'll score lower on those **Link real plugins and skills to your own Services** Point `ai-plugin-service` / `ai-skill-service` at whatever Service (or repository entity) already owns that code in your Catalog, instead of the sample `docs-portal`. - -**Set up your AI Skills catalog** - -Cortex Catalogs (the nav-level groupings like Services, Infrastructure, Domains) are UI-only today — there's no API or CLI support yet to script this step: - -1. Go to [Catalogs](https://app.getcortexapp.com/admin/catalogs) → **New Catalog** -2. Relationship type: `ai-plugin-skills` -3. Root entity type: `ai-plugin` -4. Name it **AI Skills** - -> Catalog creation will be automated once catalog API support is added to the CLI. diff --git a/cortexapps_cli/solutions/ai-skills/catalogs/ai-skills.json b/cortexapps_cli/solutions/ai-skills/catalogs/ai-skills.json new file mode 100644 index 00000000..0efd882b --- /dev/null +++ b/cortexapps_cli/solutions/ai-skills/catalogs/ai-skills.json @@ -0,0 +1,7 @@ +{ + "name": "AI Skills", + "slug": "ai-skills", + "iconTag": "list", + "isDraft": false, + "relationshipTypeTag": "ai-plugin-skills" +} diff --git a/cortexapps_cli/solutions/environments/README.md b/cortexapps_cli/solutions/environments/README.md index 940ddcc4..7af15614 100644 --- a/cortexapps_cli/solutions/environments/README.md +++ b/cortexapps_cli/solutions/environments/README.md @@ -73,16 +73,9 @@ cortex solutions install -s environments ## After Installing -**Set up your Environments catalog** +**Environments catalog** -Create a catalog to visualize the deployment hierarchy: - -1. Go to [Catalogs](https://app.getcortexapp.com/admin/catalogs) → **New Catalog** -2. Relationship type: `environments` -3. Root entity type: `environment` -4. Name it **Environments** - -> Catalog creation will be automated once catalog API support is added to the CLI. +Environments, releases, and service-versions are available in a catalog named [Environments](https://app.getcortexapp.com/admin/catalogs/environments). **Try the workflows** diff --git a/cortexapps_cli/solutions/environments/catalogs/environments.json b/cortexapps_cli/solutions/environments/catalogs/environments.json new file mode 100644 index 00000000..49f36cb8 --- /dev/null +++ b/cortexapps_cli/solutions/environments/catalogs/environments.json @@ -0,0 +1,7 @@ +{ + "name": "Environments", + "slug": "environments", + "iconTag": "database", + "isDraft": false, + "relationshipTypeTag": "environments" +} diff --git a/data/import/catalogs/cli-test-catalog.json b/data/import/catalogs/cli-test-catalog.json new file mode 100644 index 00000000..1db1081c --- /dev/null +++ b/data/import/catalogs/cli-test-catalog.json @@ -0,0 +1,12 @@ +{ + "name": "CLI Test Catalog", + "slug": "cli-test-catalog", + "iconTag": "list", + "description": "Created by the cortexapps-cli test suite", + "isDraft": false, + "filter": { + "types": { + "include": ["service"] + } + } +} diff --git a/tests/test_catalogs.py b/tests/test_catalogs.py new file mode 100644 index 00000000..740194e3 --- /dev/null +++ b/tests/test_catalogs.py @@ -0,0 +1,47 @@ +from tests.helpers.utils import * + + +def _api_enabled(): + # The public catalogs API is permission/feature gated; skip rather than + # fail when the test tenant does not have it enabled. + raw = cli(["catalogs", "list"], return_type=ReturnType.RAW) + return raw.exit_code == 0 + + +def test_list(): + if not _api_enabled(): + pytest.skip("Public catalogs API is not enabled for this tenant") + response = cli(["catalogs", "list"]) + assert "catalogPages" in response + + +def test_crud(): + if not _api_enabled(): + pytest.skip("Public catalogs API is not enabled for this tenant") + slug = "cli-test-catalog" + raw = cli( + ["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json"], + return_type=ReturnType.RAW, + ) + if raw.exit_code != 0: + pytest.skip(f"Catalog page create failed on this tenant: {raw.stdout}") + try: + response = cli(["catalogs", "list"]) + assert any( + c["slug"] == slug for c in response["catalogPages"] + ), f"Should find catalog page with slug {slug}" + + response = cli(["catalogs", "get", "-s", slug]) + assert response["slug"] == slug + assert response["name"] == "CLI Test Catalog" + + # POST is an upsert: creating again with the same slug replaces it. + cli(["catalogs", "create", "-f", "data/import/catalogs/cli-test-catalog.json"]) + response = cli(["catalogs", "get", "-s", slug]) + assert response["slug"] == slug + finally: + cli(["catalogs", "delete", "-s", slug]) + + # the page should be gone after delete + raw = cli(["catalogs", "get", "-s", slug], return_type=ReturnType.RAW) + assert raw.exit_code != 0