From 8c010d7a005a5dbe1041ea2663794060d5900d61 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 09:45:52 -0700 Subject: [PATCH 01/10] feat: add solutions package scaffold with github-starter bundle --- cortexapps_cli/solutions/__init__.py | 0 .../solutions/github-starter/README.md | 20 +++++++++++++++++++ .../scorecards/github-readiness.yaml | 15 ++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 cortexapps_cli/solutions/__init__.py create mode 100644 cortexapps_cli/solutions/github-starter/README.md create mode 100644 cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml diff --git a/cortexapps_cli/solutions/__init__.py b/cortexapps_cli/solutions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cortexapps_cli/solutions/github-starter/README.md b/cortexapps_cli/solutions/github-starter/README.md new file mode 100644 index 0000000..8caea2f --- /dev/null +++ b/cortexapps_cli/solutions/github-starter/README.md @@ -0,0 +1,20 @@ +--- +name: GitHub Starter +description: Pre-configured scorecards for a GitHub-integrated Cortex workspace. +--- + +# GitHub Starter + +This solution provides a starting point for teams using GitHub with Cortex. + +## What's Included + +- **GitHub Readiness Scorecard** — checks that services have GitHub repositories configured + +## Prerequisites + +- GitHub integration enabled in your Cortex workspace + +## After Installing + +Run `cortex scorecards list` to see the installed scorecards. diff --git a/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml b/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml new file mode 100644 index 0000000..05c58d3 --- /dev/null +++ b/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml @@ -0,0 +1,15 @@ +tag: github-readiness +name: GitHub Readiness +description: Basic checks for GitHub integration readiness +ladder: + levels: + - name: Bronze + rank: 1 + description: Basic GitHub configuration + color: "#CD7F32" +rules: + - title: Has GitHub repository + description: Service has a GitHub repository configured + expression: "git != null" + weight: 1 + level: Bronze From 001811573fda9702276d631fc67aa681766ef1b4 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 09:50:46 -0700 Subject: [PATCH 02/10] feat: add solutions command skeleton, cli wiring, and auth bypass Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/cli.py | 13 ++++ cortexapps_cli/commands/solutions.py | 90 ++++++++++++++++++++++++++++ tests/test_solutions.py | 7 +++ 3 files changed, 110 insertions(+) create mode 100644 cortexapps_cli/commands/solutions.py create mode 100644 tests/test_solutions.py diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index cfdba3c..c186a0f 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -41,6 +41,7 @@ import cortexapps_cli.commands.scim as scim import cortexapps_cli.commands.scorecards as scorecards import cortexapps_cli.commands.secrets as secrets +import cortexapps_cli.commands.solutions as solutions import cortexapps_cli.commands.teams as teams import cortexapps_cli.commands.users as users import cortexapps_cli.commands.workflows as workflows @@ -85,6 +86,17 @@ def global_callback( if ctx.invoked_subcommand == "login": return + if ctx.invoked_subcommand == "solutions": + ctx.obj["_auth_params"] = { + "api_key": api_key, + "url": url, + "config_file": config_file, + "tenant": tenant, + "log_level": log_level, + "rate_limit": rate_limit, + } + return + numeric_level = getattr(logging, log_level.upper(), None) if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level: {log_level}") @@ -264,6 +276,7 @@ def version(): app.add_typer(scim.app, name="scim") app.add_typer(scorecards.app, name="scorecards") app.add_typer(secrets.app, name="secrets") +app.add_typer(solutions.app, name="solutions") app.add_typer(teams.app, name="teams") app.add_typer(users.app, name="users") app.command()(version) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py new file mode 100644 index 0000000..35e6110 --- /dev/null +++ b/cortexapps_cli/commands/solutions.py @@ -0,0 +1,90 @@ +import configparser +import logging +import os +import re +from importlib.resources import as_file, files + +import typer +import yaml +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table + +from cortexapps_cli.cortex_client import CortexClient + +app = typer.Typer(help="Solutions commands", no_args_is_help=True) +console = Console() + + +def _solutions_root(): + return files("cortexapps_cli.solutions") + + +def _list_solution_tags() -> list[str]: + root = _solutions_root() + return sorted( + item.name + for item in root.iterdir() + if item.is_dir() and not item.name.startswith("_") + ) + + +def _parse_frontmatter(content: str) -> dict: + """Parse YAML frontmatter block from README content.""" + match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL) + if not match: + return {} + try: + return yaml.safe_load(match.group(1)) or {} + except yaml.YAMLError: + return {} + + +def _get_readme(tag: str) -> str | None: + """Return README.md content for a solution tag, or None if not found.""" + try: + return (_solutions_root() / tag / "README.md").read_text(encoding="utf-8") + except Exception: + return None + + +def _build_client(ctx: typer.Context) -> CortexClient: + """Build a CortexClient from auth params stored by global_callback.""" + params = ctx.obj.get("_auth_params", {}) + api_key = params.get("api_key") + url = params.get("url") + config_file = params.get( + "config_file", + os.path.join(os.path.expanduser("~"), ".cortex", "config"), + ) + tenant = params.get("tenant", "default") + log_level_str = params.get("log_level", "WARNING") + rate_limit = params.get("rate_limit") + + if not os.path.isfile(config_file): + if not api_key: + typer.echo( + "Error: Authentication required. Run 'cortex login' first or set CORTEX_API_KEY." + ) + raise typer.Exit(1) + else: + config = configparser.ConfigParser() + config.read(config_file) + if not api_key: + if tenant not in config: + typer.echo( + f"Error: Tenant '{tenant}' not found in config. Run 'cortex login' first." + ) + raise typer.Exit(1) + api_key = config[tenant]["api_key"] + if not url: + url = config[tenant].get("base_url", "https://api.getcortexapp.com") + + if not url: + url = "https://api.getcortexapp.com" + + api_key = api_key.strip("\"' ") + url = url.strip("\"' /") + + numeric_level = getattr(logging, log_level_str.upper(), logging.WARNING) + return CortexClient(api_key, tenant, numeric_level, url, rate_limit) diff --git a/tests/test_solutions.py b/tests/test_solutions.py new file mode 100644 index 0000000..f2faaab --- /dev/null +++ b/tests/test_solutions.py @@ -0,0 +1,7 @@ +from tests.helpers.utils import cli, ReturnType + + +def test_solutions_help(): + result = cli(["solutions", "--help"], return_type=ReturnType.RAW) + assert result.exit_code == 0 + assert "solutions" in result.output.lower() From 02655a1c984771a7de099a3a83c5c6a79bfe0e6f Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 09:53:30 -0700 Subject: [PATCH 03/10] feat: add solutions list command Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 17 +++++++++++++++++ tests/test_solutions.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 35e6110..3e55ec1 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -88,3 +88,20 @@ def _build_client(ctx: typer.Context) -> CortexClient: numeric_level = getattr(logging, log_level_str.upper(), logging.WARNING) return CortexClient(api_key, tenant, numeric_level, url, rate_limit) + + +@app.command("list") +def list_solutions(ctx: typer.Context): + """List all available solutions.""" + tags = _list_solution_tags() + table = Table(title="Available Solutions") + table.add_column("Tag", style="cyan", no_wrap=True) + table.add_column("Name") + table.add_column("Description") + for tag in tags: + readme = _get_readme(tag) + if readme is None: + continue + fm = _parse_frontmatter(readme) + table.add_row(tag, fm.get("name", tag), fm.get("description", "")) + console.print(table) diff --git a/tests/test_solutions.py b/tests/test_solutions.py index f2faaab..4e9ec68 100644 --- a/tests/test_solutions.py +++ b/tests/test_solutions.py @@ -5,3 +5,21 @@ def test_solutions_help(): result = cli(["solutions", "--help"], return_type=ReturnType.RAW) assert result.exit_code == 0 assert "solutions" in result.output.lower() + + +def test_solutions_list_shows_tag(): + result = cli(["solutions", "list"], return_type=ReturnType.RAW) + assert result.exit_code == 0, result.output + assert "github-starter" in result.output + + +def test_solutions_list_shows_name(): + result = cli(["solutions", "list"], return_type=ReturnType.RAW) + assert result.exit_code == 0, result.output + assert "GitHub Starter" in result.output + + +def test_solutions_list_shows_description(): + result = cli(["solutions", "list"], return_type=ReturnType.RAW) + assert result.exit_code == 0, result.output + assert "GitHub-integrated" in result.output From de173603d9f459052e1cff9e714db027d2f7ada6 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 09:56:15 -0700 Subject: [PATCH 04/10] feat: add solutions info command Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 14 ++++++++++++++ tests/test_solutions.py | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 3e55ec1..64e60dd 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -105,3 +105,17 @@ def list_solutions(ctx: typer.Context): fm = _parse_frontmatter(readme) table.add_row(tag, fm.get("name", tag), fm.get("description", "")) console.print(table) + + +@app.command() +def info( + ctx: typer.Context, + solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), +): + """Show README for a solution.""" + readme = _get_readme(solution) + if readme is None: + avail = ", ".join(_list_solution_tags()) + typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") + raise typer.Exit(1) + console.print(Markdown(readme)) diff --git a/tests/test_solutions.py b/tests/test_solutions.py index 4e9ec68..38dd035 100644 --- a/tests/test_solutions.py +++ b/tests/test_solutions.py @@ -23,3 +23,15 @@ def test_solutions_list_shows_description(): result = cli(["solutions", "list"], return_type=ReturnType.RAW) assert result.exit_code == 0, result.output assert "GitHub-integrated" in result.output + + +def test_solutions_info_known_tag(): + result = cli(["solutions", "info", "-s", "github-starter"], return_type=ReturnType.RAW) + assert result.exit_code == 0, result.output + assert "GitHub Starter" in result.output + + +def test_solutions_info_unknown_tag(): + result = cli(["solutions", "info", "-s", "nonexistent-xyz-abc"], return_type=ReturnType.RAW) + assert result.exit_code == 1 + assert "not found" in result.output.lower() From a6a21a7362506ae744e8fce28d356eac9fb7c651 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 10:00:01 -0700 Subject: [PATCH 05/10] feat: add solutions install command Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 20 ++++++++++++++++++++ tests/test_solutions.py | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 64e60dd..5dbb30b 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -119,3 +119,23 @@ def info( typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") raise typer.Exit(1) console.print(Markdown(readme)) + + +@app.command() +def install( + ctx: typer.Context, + solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), + force: bool = typer.Option(False, "--force", help="Recreate entities if they already exist"), +): + """Install a solution into the current Cortex workspace.""" + if solution not in _list_solution_tags(): + avail = ", ".join(_list_solution_tags()) + typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") + raise typer.Exit(1) + + ctx.obj["client"] = _build_client(ctx) + + import cortexapps_cli.commands.backup as backup + + with as_file(_solutions_root() / solution) as solution_path: + backup.import_tenant(ctx, directory=str(solution_path), force=force) diff --git a/tests/test_solutions.py b/tests/test_solutions.py index 38dd035..6052bf8 100644 --- a/tests/test_solutions.py +++ b/tests/test_solutions.py @@ -35,3 +35,23 @@ def test_solutions_info_unknown_tag(): result = cli(["solutions", "info", "-s", "nonexistent-xyz-abc"], return_type=ReturnType.RAW) assert result.exit_code == 1 assert "not found" in result.output.lower() + + +def test_solutions_install_unknown_tag(): + # Unknown-tag check runs before auth check, so no credentials needed + result = cli(["solutions", "install", "-s", "nonexistent-xyz-abc"], return_type=ReturnType.RAW) + assert result.exit_code == 1 + assert "not found" in result.output.lower() + + +def test_solutions_install_no_auth(): + # Known tag, but no credentials configured — should fail with auth error + # This test only applies when no config file or CORTEX_API_KEY is present. + # Skip if the test environment has credentials set up. + import os + if os.path.isfile(os.path.join(os.path.expanduser("~"), ".cortex", "config")): + import pytest + pytest.skip("Skipping: credentials are configured in this environment") + result = cli(["solutions", "install", "-s", "github-starter"], return_type=ReturnType.RAW) + assert result.exit_code == 1 + assert "authentication required" in result.output.lower() From 9b140a71d355ba6dd486507a37d06508d24d0230 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 11:17:32 -0700 Subject: [PATCH 06/10] fix: handle missing api_key in config section in _build_client Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 5dbb30b..942758d 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -76,7 +76,12 @@ def _build_client(ctx: typer.Context) -> CortexClient: f"Error: Tenant '{tenant}' not found in config. Run 'cortex login' first." ) raise typer.Exit(1) - api_key = config[tenant]["api_key"] + api_key = config[tenant].get("api_key") + if not api_key: + typer.echo( + f"Error: No api_key found for tenant '{tenant}' in config. Run 'cortex login' first." + ) + raise typer.Exit(1) if not url: url = config[tenant].get("base_url", "https://api.getcortexapp.com") From 485a061623aa0749f06c7e0970a2c58a6efca9c3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 11:25:34 -0700 Subject: [PATCH 07/10] fix: strip frontmatter and left-justify headings in solutions info Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 942758d..da694dc 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -112,6 +112,34 @@ def list_solutions(ctx: typer.Context): console.print(table) +def _print_readme(text: str) -> None: + """Render README with left-justified headings and Rich Markdown body blocks.""" + # Strip YAML frontmatter + body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL).strip() + + # Process line by line: headings get Rich markup; everything else is + # collected into blocks and rendered via Markdown (preserving bullets, code, etc.) + heading_styles = {"# ": "bold", "## ": "bold underline", "### ": "bold"} + pending: list[str] = [] + + def flush() -> None: + block = "\n".join(pending).strip() + if block: + console.print(Markdown(block)) + pending.clear() + + for line in body.split("\n"): + for prefix, style in heading_styles.items(): + if line.startswith(prefix): + flush() + console.print(f"\n[{style}]{line[len(prefix):]}[/{style}]") + break + else: + pending.append(line) + + flush() + + @app.command() def info( ctx: typer.Context, @@ -123,7 +151,7 @@ def info( avail = ", ".join(_list_solution_tags()) typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") raise typer.Exit(1) - console.print(Markdown(readme)) + _print_readme(readme) @app.command() From 4b996c271127cbb4217df9aab7f420492963cb6b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 11:28:06 -0700 Subject: [PATCH 08/10] feat: show README after install by default (--no-info to suppress); update github-starter README with install instructions Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 7 +++++++ cortexapps_cli/solutions/github-starter/README.md | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index da694dc..194f050 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -159,6 +159,7 @@ def install( ctx: typer.Context, solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), force: bool = typer.Option(False, "--force", help="Recreate entities if they already exist"), + show_info: bool = typer.Option(True, "--info/--no-info", help="Show solution README after installing"), ): """Install a solution into the current Cortex workspace.""" if solution not in _list_solution_tags(): @@ -172,3 +173,9 @@ def install( with as_file(_solutions_root() / solution) as solution_path: backup.import_tenant(ctx, directory=str(solution_path), force=force) + + if show_info: + readme = _get_readme(solution) + if readme: + console.print() + _print_readme(readme) diff --git a/cortexapps_cli/solutions/github-starter/README.md b/cortexapps_cli/solutions/github-starter/README.md index 8caea2f..86cebef 100644 --- a/cortexapps_cli/solutions/github-starter/README.md +++ b/cortexapps_cli/solutions/github-starter/README.md @@ -15,6 +15,19 @@ This solution provides a starting point for teams using GitHub with Cortex. - GitHub integration enabled in your Cortex workspace +## Installation + +``` +cortex solutions install -s github-starter +``` + +To overwrite existing resources: + +``` +cortex solutions install -s github-starter --force +``` + ## After Installing -Run `cortex scorecards list` to see the installed scorecards. +Run `cortex scorecards list` to confirm the scorecard was imported, then navigate +to the Scorecards page in the Cortex UI to see scores across your services. From ef8c766c2325753fd439a16f426cf192ac6a07d4 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 11:29:21 -0700 Subject: [PATCH 09/10] fix: render code blocks as cyan text instead of Rich dark panels in solutions info Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 194f050..a1c799e 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -117,10 +117,9 @@ def _print_readme(text: str) -> None: # Strip YAML frontmatter body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL).strip() - # Process line by line: headings get Rich markup; everything else is - # collected into blocks and rendered via Markdown (preserving bullets, code, etc.) heading_styles = {"# ": "bold", "## ": "bold underline", "### ": "bold"} pending: list[str] = [] + in_code_block = False def flush() -> None: block = "\n".join(pending).strip() @@ -129,6 +128,16 @@ def flush() -> None: pending.clear() for line in body.split("\n"): + if line.startswith("```"): + if not in_code_block: + flush() + in_code_block = not in_code_block + continue + + if in_code_block: + console.print(f" [cyan]{line}[/cyan]" if line else "") + continue + for prefix, style in heading_styles.items(): if line.startswith(prefix): flush() From 1f88719b15da9d8d71efd7e572a2b181b2de3260 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 14 Jul 2026 11:33:19 -0700 Subject: [PATCH 10/10] docs: add ASCII architecture diagram to github-starter README Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-starter/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cortexapps_cli/solutions/github-starter/README.md b/cortexapps_cli/solutions/github-starter/README.md index 86cebef..b6901e4 100644 --- a/cortexapps_cli/solutions/github-starter/README.md +++ b/cortexapps_cli/solutions/github-starter/README.md @@ -7,6 +7,23 @@ description: Pre-configured scorecards for a GitHub-integrated Cortex workspace. This solution provides a starting point for teams using GitHub with Cortex. +## Overview + +``` +┌────────────────┐ GitHub integration ┌─────────────────┐ +│ GitHub Repo │ ─────────────────────▶ │ Service Entity │ +└────────────────┘ └────────┬────────┘ + │ evaluated by + ▼ + ┌─────────────────────┐ + │ GitHub Readiness │ + │ Scorecard │ + ├─────────────────────┤ + │ 🥉 Bronze │ + │ git != null │ + └─────────────────────┘ +``` + ## What's Included - **GitHub Readiness Scorecard** — checks that services have GitHub repositories configured