Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions cortexapps_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import os
import sys
import importlib.metadata
import tomllib
import configparser
import logging
import webbrowser
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
67 changes: 63 additions & 4 deletions cortexapps_cli/commands/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -257,6 +272,7 @@ def export(

Exports the following objects:
- catalog
- catalogs
- entity-types
- entity-relationship-types
- entity-relationships
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
124 changes: 124 additions & 0 deletions cortexapps_cli/commands/catalogs.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 8 additions & 2 deletions cortexapps_cli/commands/solutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand All @@ -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}"),
Expand Down
15 changes: 4 additions & 11 deletions cortexapps_cli/solutions/ai-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

11 changes: 11 additions & 0 deletions cortexapps_cli/solutions/ai-agents/catalogs/ai-agents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "AI Agents",
"slug": "ai-agents",
"iconTag": "list",
"isDraft": false,
"filter": {
"types": {
"include": ["ai-agent"]
}
}
}
15 changes: 4 additions & 11 deletions cortexapps_cli/solutions/ai-skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
7 changes: 7 additions & 0 deletions cortexapps_cli/solutions/ai-skills/catalogs/ai-skills.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "AI Skills",
"slug": "ai-skills",
"iconTag": "list",
"isDraft": false,
"relationshipTypeTag": "ai-plugin-skills"
}
Loading
Loading