Skip to content

Commit c3a3c29

Browse files
Merge pull request #238 from cortexapps/345-catalogs-command
feat: add catalogs command for the public catalog-pages API
2 parents 164661d + 2164b3e commit c3a3c29

12 files changed

Lines changed: 294 additions & 44 deletions

File tree

‎cortexapps_cli/cli.py‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import os
77
import sys
88
import importlib.metadata
9-
import tomllib
109
import configparser
1110
import logging
1211
import webbrowser
@@ -19,6 +18,7 @@
1918
import cortexapps_cli.commands.audit_logs as audit_logs
2019
import cortexapps_cli.commands.backup as backup
2120
import cortexapps_cli.commands.catalog as catalog
21+
import cortexapps_cli.commands.catalogs as catalogs
2222
import cortexapps_cli.commands.custom_data as custom_data
2323
import cortexapps_cli.commands.custom_events as custom_events
2424
import cortexapps_cli.commands.custom_metrics as custom_metrics
@@ -244,12 +244,9 @@ def version():
244244
"""
245245
Show the version and exit
246246
"""
247-
try:
248-
with open("pyproject.toml", "rb") as f:
249-
pyproject = tomllib.load(f)
250-
version = pyproject["tool"]["poetry"]["version"]
251-
except Exception as e:
252-
version = importlib.metadata.version('cortexapps_cli')
247+
version = importlib.metadata.version('cortexapps_cli')
248+
if version == "0.0.0":
249+
version = "0.0.0 (dev)"
253250
print(version)
254251

255252
# Register all commands alphabetically so they appear in order in --help
@@ -258,6 +255,7 @@ def version():
258255
app.add_typer(audit_logs.app, name="audit-logs")
259256
app.add_typer(backup.app, name="backup")
260257
app.add_typer(catalog.app, name="catalog")
258+
app.add_typer(catalogs.app, name="catalogs")
261259
app.add_typer(custom_data.app, name="custom-data")
262260
app.add_typer(custom_events.app, name="custom-events")
263261
app.add_typer(custom_metrics.app, name="custom-metrics")

‎cortexapps_cli/commands/backup.py‎

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import cortexapps_cli.commands.scorecards as scorecards
1919
import cortexapps_cli.commands.catalog as catalog
20+
import cortexapps_cli.commands.catalogs as catalogs
2021
import cortexapps_cli.commands.entity_types as entity_types
2122
import cortexapps_cli.commands.entity_relationship_types as entity_relationship_types
2223
import cortexapps_cli.commands.entity_relationships as entity_relationships
@@ -206,16 +207,30 @@ def _export_workflows(ctx, directory):
206207
except Exception as e:
207208
print(f"Failed to export workflow {tag}: {e}")
208209

209-
backupTypes = {
210+
def _export_catalog_pages(ctx, directory):
211+
directory = _directory_name(directory, "catalogs")
212+
213+
data = catalogs.list(ctx, _print=False)
214+
pages = sorted(data.get("catalogPages", []), key=lambda x: x["slug"])
215+
216+
for page in pages:
217+
slug = page["slug"]
218+
try:
219+
_file_name(directory, slug, json.dumps(page, indent=2), "json")
220+
except Exception as e:
221+
print(f"Failed to export catalog page {slug}: {e}")
222+
223+
backupTypes = sorted([
210224
"catalog",
211-
"entity-types",
225+
"catalogs",
212226
"entity-relationship-types",
213227
"entity-relationships",
228+
"entity-types",
214229
"ip-allowlist",
215230
"plugins",
216231
"scorecards",
217-
"workflows"
218-
}
232+
"workflows",
233+
])
219234
backupString = ','.join(backupTypes)
220235

221236
def _parse_export_types(value: str) -> List[str]:
@@ -257,6 +272,7 @@ def export(
257272
258273
Exports the following objects:
259274
- catalog
275+
- catalogs
260276
- entity-types
261277
- entity-relationship-types
262278
- entity-relationships
@@ -310,6 +326,8 @@ def export(
310326
_export_scorecards(ctx, directory)
311327
if "workflows" in export_types:
312328
_export_workflows(ctx, directory)
329+
if "catalogs" in export_types:
330+
_export_catalog_pages(ctx, directory)
313331

314332
print("\nExport complete!")
315333
print("Contents available in " + directory)
@@ -744,6 +762,44 @@ def import_scorecard_file(file_info):
744762

745763
return ("scorecards", len(results) - failed_count, [(fp, et, em) for fn, fp, et, em in results if et])
746764

765+
def _import_catalog_pages(ctx, directory):
766+
results = []
767+
failed_count = 0
768+
if os.path.isdir(directory):
769+
print("Processing: " + directory)
770+
files = [(filename, os.path.join(directory, filename))
771+
for filename in sorted(os.listdir(directory))
772+
if os.path.isfile(os.path.join(directory, filename))]
773+
774+
def import_catalog_page_file(file_info):
775+
filename, file_path = file_info
776+
try:
777+
with open(file_path) as f:
778+
catalogs.create(ctx, file_input=f, _print=False)
779+
return (filename, file_path, None, None)
780+
except typer.Exit as e:
781+
return (filename, file_path, "HTTP", "Validation or HTTP error")
782+
except Exception as e:
783+
return (filename, file_path, type(e).__name__, str(e))
784+
785+
# Import all files in parallel
786+
with ThreadPoolExecutor(max_workers=10) as executor:
787+
futures = {executor.submit(import_catalog_page_file, file_info): file_info[0] for file_info in files}
788+
results = []
789+
for future in as_completed(futures):
790+
results.append(future.result())
791+
792+
# Print results in alphabetical order
793+
failed_count = 0
794+
for filename, file_path, error_type, error_msg in sorted(results, key=lambda x: x[0]):
795+
if error_type:
796+
print(f" Failed to import {filename}: {error_type} - {error_msg}")
797+
failed_count += 1
798+
else:
799+
print(f" Importing: {filename}")
800+
801+
return ("catalogs", len(results) - failed_count, [(fp, et, em) for fn, fp, et, em in results if et])
802+
747803
def _import_workflows(ctx, directory):
748804
results = []
749805
failed_count = 0
@@ -805,6 +861,7 @@ def import_tenant(
805861
all_stats.append(_import_plugins(ctx, directory + "/plugins"))
806862
all_stats.append(_import_scorecards(ctx, directory + "/scorecards"))
807863
all_stats.append(_import_workflows(ctx, directory + "/workflows"))
864+
all_stats.append(_import_catalog_pages(ctx, directory + "/catalogs"))
808865

809866
# Print summary
810867
print("\n" + "="*80)
@@ -861,6 +918,8 @@ def import_tenant(
861918
print(f"cortex scorecards create -f \"{file_path}\"")
862919
elif import_type == "workflows":
863920
print(f"cortex workflows create -f \"{file_path}\"")
921+
elif import_type == "catalogs":
922+
print(f"cortex catalogs create -f \"{file_path}\"")
864923

865924
# Exit with non-zero code if any imports failed
866925
if total_failed > 0:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from cortexapps_cli.command_options import CommandOptions
2+
from cortexapps_cli.command_options import ListCommandOptions
3+
from cortexapps_cli.utils import print_output_with_context, print_output
4+
from typing_extensions import Annotated
5+
import json
6+
import typer
7+
import yaml
8+
9+
app = typer.Typer(
10+
help="Catalog page commands",
11+
no_args_is_help=True
12+
)
13+
14+
def _read_definition(file_input):
15+
"""Parse a catalog page definition from a JSON or YAML file into a dict.
16+
17+
The definition is always sent to the API as JSON, so a YAML file is parsed
18+
and re-serialized rather than posted verbatim.
19+
"""
20+
content = file_input.read()
21+
try:
22+
return json.loads(content)
23+
except json.JSONDecodeError:
24+
pass
25+
try:
26+
return yaml.safe_load(content)
27+
except yaml.YAMLError:
28+
raise typer.BadParameter("Input file is neither valid JSON nor YAML.")
29+
30+
@app.command()
31+
def list(
32+
ctx: typer.Context,
33+
_print: CommandOptions._print = True,
34+
page: ListCommandOptions.page = None,
35+
page_size: ListCommandOptions.page_size = 250,
36+
table_output: ListCommandOptions.table_output = False,
37+
csv_output: ListCommandOptions.csv_output = False,
38+
columns: ListCommandOptions.columns = [],
39+
no_headers: ListCommandOptions.no_headers = False,
40+
filters: ListCommandOptions.filters = [],
41+
sort: ListCommandOptions.sort = [],
42+
):
43+
"""
44+
List catalog pages.
45+
"""
46+
47+
client = ctx.obj["client"]
48+
49+
params = {
50+
"page": page,
51+
"pageSize": page_size
52+
}
53+
54+
if (table_output or csv_output) and not ctx.params.get('columns'):
55+
ctx.params['columns'] = [
56+
"Name=name",
57+
"Slug=slug",
58+
"Type=type",
59+
"Description=description",
60+
]
61+
62+
# remove any params that are None
63+
params = {k: v for k, v in params.items() if v is not None}
64+
65+
if page is None:
66+
# if page is not specified, we want to fetch all pages
67+
r = client.fetch("api/v1/catalog-pages", params=params)
68+
else:
69+
# if page is specified, we want to fetch only that page
70+
r = client.get("api/v1/catalog-pages", params=params)
71+
72+
if _print:
73+
print_output_with_context(ctx, r)
74+
else:
75+
return(r)
76+
77+
@app.command()
78+
def get(
79+
ctx: typer.Context,
80+
slug: str = typer.Option(..., "--slug", "-s", help="The slug of the catalog page"),
81+
_print: CommandOptions._print = True,
82+
):
83+
"""
84+
Retrieve a catalog page by slug.
85+
"""
86+
87+
client = ctx.obj["client"]
88+
89+
r = client.get("api/v1/catalog-pages/" + slug)
90+
91+
if _print:
92+
print_output_with_context(ctx, r)
93+
else:
94+
return(r)
95+
96+
@app.command()
97+
def create(
98+
ctx: typer.Context,
99+
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-")],
100+
_print: CommandOptions._print = True,
101+
):
102+
"""
103+
Create a catalog page, or replace the existing one with the same slug. API key must have the Edit Catalogs permission.
104+
"""
105+
106+
client = ctx.obj["client"]
107+
108+
data = _read_definition(file_input)
109+
r = client.post("api/v1/catalog-pages", data=data)
110+
if _print:
111+
print_output(r)
112+
113+
@app.command()
114+
def delete(
115+
ctx: typer.Context,
116+
slug: str = typer.Option(..., "--slug", "-s", help="The slug of the catalog page"),
117+
):
118+
"""
119+
Delete a catalog page by slug. API key must have the Edit Catalogs permission.
120+
"""
121+
122+
client = ctx.obj["client"]
123+
124+
client.delete("api/v1/catalog-pages/" + slug)

‎cortexapps_cli/commands/solutions.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,11 @@ def _read_resource_tag(content: str, kind: str) -> str | None:
334334
data = yaml.safe_load(content)
335335
if not isinstance(data, dict):
336336
data = json.loads(content)
337-
return data.get("type") if kind == "entity-types" else data.get("tag")
337+
if kind == "entity-types":
338+
return data.get("type")
339+
if kind == "catalogs":
340+
return data.get("slug")
341+
return data.get("tag")
338342
except Exception:
339343
return None
340344

@@ -348,6 +352,7 @@ def _collect_solution_resources(path: Path) -> dict[str, list[str]]:
348352
"scorecards": [],
349353
"workflows": [],
350354
"plugins": [],
355+
"catalogs": [],
351356
}
352357
for kind in resources:
353358
subdir = path / kind
@@ -404,7 +409,7 @@ def _run_uninstall(client, path: Path, yes: bool) -> None:
404409
return
405410

406411
typer.echo("\nThis will remove the following entities:")
407-
for kind in ("workflows", "scorecards", "plugins", "catalog", "entity-relationship-types", "entity-types"):
412+
for kind in ("catalogs", "workflows", "scorecards", "plugins", "catalog", "entity-relationship-types", "entity-types"):
408413
count = len(resources[kind])
409414
if count:
410415
typer.echo(f" {kind}: {count}")
@@ -418,6 +423,7 @@ def _run_uninstall(client, path: Path, yes: bool) -> None:
418423

419424
# Delete in reverse import order
420425
steps = [
426+
("catalogs", lambda t: f"api/v1/catalog-pages/{t}"),
421427
("workflows", lambda t: f"api/v1/workflows/{t}"),
422428
("scorecards", lambda t: f"api/v1/scorecards/{t}"),
423429
("plugins", lambda t: f"api/v1/plugins/{t}"),

‎cortexapps_cli/solutions/ai-agents/README.md‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ cortex solutions install -s ai-agents
4848

4949
## After Installing
5050

51+
**AI Agents catalog**
52+
53+
AI agents are available in a catalog named [AI Agents](https://app.getcortexapp.com/admin/catalogs/ai-agents).
54+
5155
**Replace the sample entities with your real agents**
5256

5357
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:
112116
**Reaching Gold**
113117

114118
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.
115-
116-
**Create an AI Agents Catalog in the UI**
117-
118-
Cortex Catalogs (the nav-level groupings) are UI-only today — no API or CLI support yet:
119-
120-
1. Go to [Catalogs](https://app.getcortexapp.com/admin/catalogs) → **New Catalog**
121-
2. Entity type: `ai-agent`
122-
3. Name it **AI Agents**
123-
124-
> Catalog creation will be automated once catalog API support is added to the CLI.
125-
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "AI Agents",
3+
"slug": "ai-agents",
4+
"iconTag": "list",
5+
"isDraft": false,
6+
"filter": {
7+
"types": {
8+
"include": ["ai-agent"]
9+
}
10+
}
11+
}

‎cortexapps_cli/solutions/ai-skills/README.md‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ cortex solutions install -s ai-skills
5555

5656
## After Installing
5757

58+
**AI Skills catalog**
59+
60+
AI plugins and skills are available in a catalog named [AI Skills](https://app.getcortexapp.com/admin/catalogs/ai-skills).
61+
5862
**Populate quality data for real skills**
5963

6064
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
7276
**Link real plugins and skills to your own Services**
7377

7478
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`.
75-
76-
**Set up your AI Skills catalog**
77-
78-
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:
79-
80-
1. Go to [Catalogs](https://app.getcortexapp.com/admin/catalogs) → **New Catalog**
81-
2. Relationship type: `ai-plugin-skills`
82-
3. Root entity type: `ai-plugin`
83-
4. Name it **AI Skills**
84-
85-
> Catalog creation will be automated once catalog API support is added to the CLI.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "AI Skills",
3+
"slug": "ai-skills",
4+
"iconTag": "list",
5+
"isDraft": false,
6+
"relationshipTypeTag": "ai-plugin-skills"
7+
}

0 commit comments

Comments
 (0)