Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7188c45
docs: add terraform solution design spec (CX-43)
jeff-schnitter Sep 2, 2026
569635c
docs: update terraform solution spec with domain-split file structure…
jeff-schnitter Sep 2, 2026
2c67e0e
docs: add terraform solution implementation plan (CX-43)
jeff-schnitter Sep 2, 2026
d7775d6
feat: add terraform solution scaffold and README (CX-43)
jeff-schnitter Sep 2, 2026
cd0fe59
feat: add terraform provider config files (CX-43)
jeff-schnitter Sep 2, 2026
3cd5911
feat: add teams.tf for terraform solution (CX-43)
jeff-schnitter Sep 2, 2026
35e82d5
feat: add ecommerce.tf for terraform solution (CX-43)
jeff-schnitter Sep 2, 2026
e53be4a
feat: add supply-chain.tf for terraform solution (CX-43)
jeff-schnitter Sep 2, 2026
6427bca
feat: add scorecards.tf for terraform solution (CX-43)
jeff-schnitter Sep 2, 2026
2f88b46
feat: add terraform-delta files for terraform solution (CX-43)
jeff-schnitter Sep 2, 2026
1e6398a
feat: add setup.py for terraform solution (CX-43)
jeff-schnitter Sep 2, 2026
c2b6efc
fix: correct team entity type and README cleanup (CX-43)
jeff-schnitter Sep 2, 2026
5b101b5
fix: correct team member schema and scorecard expressions (CX-43)
jeff-schnitter Sep 2, 2026
868f2f4
fix: use ownership.teams().length expressions in scorecard (CX-43)
jeff-schnitter Sep 2, 2026
7acff09
fix: always copy template files so re-runs pick up updates (CX-43)
jeff-schnitter Sep 2, 2026
dee1eb8
fix: remove type=\"team\" from team entities, fix custom data express…
jeff-schnitter Sep 2, 2026
571df30
fix: replace links.exists() with links.size() >= 2 for Gold rule (CX-43)
jeff-schnitter Sep 2, 2026
f91c42e
fix: use links("runbook").length > 0 for Gold runbook rule (CX-43)
jeff-schnitter Sep 2, 2026
afab489
fix: links.size() > 0 -> links().length > 0 for Silver rule (CX-43)
jeff-schnitter Sep 2, 2026
2d9fb68
fix: try links.length > 0 for Silver has-any-link rule (CX-43)
jeff-schnitter Sep 2, 2026
cb2427f
fix: use named link types in all scorecard link rules (CX-43)
jeff-schnitter Sep 2, 2026
221f7ad
fix: define ladder levels in ascending rank order (CX-43)
jeff-schnitter Sep 2, 2026
40080e5
fix: rename scorecard to avoid collision with customer scorecards (CX…
jeff-schnitter Sep 2, 2026
d9d073b
fix: scope scorecard to terraform-demo group to avoid evaluating all …
jeff-schnitter Sep 2, 2026
d31270c
fix: add After Installing section to README for post-install menu (CX…
jeff-schnitter Sep 2, 2026
b316457
fix: add Data Model ASCII diagram to README for post-install menu (CX…
jeff-schnitter Sep 2, 2026
6486e40
fix: hide Import report menu option when import report is empty (CX-43)
jeff-schnitter Sep 2, 2026
7f5fb9b
feat: hyperlink .tf filenames in Data Model diagram to GitHub blob UR…
jeff-schnitter Sep 2, 2026
10a3e73
fix: hyperlink entity tags in Data Model diagram for terraform soluti…
jeff-schnitter Sep 2, 2026
5e6a2a9
feat: add terraform source links to all entities; add catalog creatio…
jeff-schnitter Sep 2, 2026
9319ce6
fix: entity page URLs from /admin/resources?tag= to /admin/service/; …
jeff-schnitter Sep 2, 2026
dbf1986
fix: revert entity URLs back to /admin/resources?tag=; fix deploys UR…
jeff-schnitter Sep 2, 2026
9c8ceb2
fix: scorecard tags link to /admin/scorecards/; domain source links; …
jeff-schnitter Sep 2, 2026
5f351dd
fix: use numeric scorecard ID for UI hyperlinks; update URL skill
jeff-schnitter Sep 3, 2026
cd9311f
chore: clarify scorecard link wording in README (links to list page)
jeff-schnitter Sep 3, 2026
b1a2233
fix: prefix all terraform demo entity tags with terraform-demo-
jeff-schnitter Sep 3, 2026
bde1fa1
fix: remove spurious 404 from scorecard id lookup in terraform solution
jeff-schnitter Sep 3, 2026
3cb3136
fix: remove scorecard hyperlink from data model diagram
jeff-schnitter Sep 3, 2026
8638e6f
fix: correct team type, link type, role format, and scorecard CQL (CX…
jeff-schnitter Sep 3, 2026
002086c
fix: revert ownership CQL to clean expression (no null-safe needed)
jeff-schnitter Sep 3, 2026
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
100 changes: 100 additions & 0 deletions .claude/skills/cortex-app-urls/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
name: cortex-app-urls
description: Use when generating or displaying URLs to the Cortex web application — entity pages, scorecard pages, or any link to the Cortex UI. Covers the correct URL patterns for app.getcortexapp.com and how to derive the app URL from an API base URL.
---

# Cortex App URL Patterns

## STOP — memorize this before writing any URL or user-facing text

The entity page URL is `/admin/resources?tag=<tag>` for ALL entity types (service, domain, team, resource).
The scorecard URL requires a **numeric ID**, not a tag: `/admin/scorecards/<numeric-id>`.

Do not guess. Use the table below.

## Terminology

The correct term is **entity** (or **entities**). The word **resource** / **resources** is retired and must not appear in user-facing output, CLI messages, or documentation. The URL path `/admin/resources` is a legacy route but is the correct one to use for entity pages.

## API vs App domains

| Purpose | Domain |
|---------|--------|
| API calls | `api.getcortexapp.com` |
| Web UI links | `app.getcortexapp.com` |

When you have an API base URL (e.g. from `CORTEX_BASE_URL` or the CLI session), derive the app URL by replacing `api.` with `app.`:

```python
app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url
```

## Entity page URL (ALL entity types)

```
https://app.getcortexapp.com/admin/resources?tag=<entity-tag>
```

This works for services, domains, teams, and resources — all use `/admin/resources?tag=<tag>`.

Example: `https://app.getcortexapp.com/admin/resources?tag=phoenix`

**NOT** `/admin/service/<tag>` — returns "No entity" for teams and domains.
**NOT** `/admin/catalog/<tag>` — that path does not exist.

## Scorecard page URL (requires numeric ID, not tag)

```
https://app.getcortexapp.com/admin/scorecards/<numeric-id>
```

The scorecard detail page uses a numeric `id`, not the tag. Look up the ID first:

```
GET /api/v1/scorecards/{tag} → response["id"]
```

Then build: `https://app.getcortexapp.com/admin/scorecards/{id}`

**In code — fetch and fall back gracefully:**

```python
def _fetch_scorecard_id_map(client, scorecard_tags: set[str]) -> dict[str, str]:
"""Map tag → url_id. Falls back to tag string if API lookup fails."""
result: dict[str, str] = {}
for tag in scorecard_tags:
try:
data = client.get(f"api/v1/scorecards/{tag}")
sc_id = data.get("id")
result[tag] = str(sc_id) if sc_id is not None else tag
except Exception:
result[tag] = tag
return result
```

## Deploys page URL (entity subpage — requires numeric ID, not tag)

```
https://app.getcortexapp.com/admin/service/<numeric-entity-id>/deploys
```

Use `GET /api/v1/catalog/<tag>` → `.id` to get the numeric ID.

## Other common pages

| Page | URL pattern |
|------|-------------|
| Entity (all types) | `https://app.getcortexapp.com/admin/resources?tag=<tag>` |
| Scorecard | `https://app.getcortexapp.com/admin/scorecards/<numeric-id>` |
| Catalogs | `https://app.getcortexapp.com/admin/catalogs` |
| Initiatives | `https://app.getcortexapp.com/admin/initiatives` |

## In code

When generating clickable links in terminal output, use OSC 8 hyperlinks for iTerm2/compatible terminals:

```python
def _hyperlink(url: str, text: str = None) -> str:
label = text if text is not None else url
return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
```
121 changes: 109 additions & 12 deletions cortexapps_cli/commands/solutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,84 @@ def _apply_hyperlinks(line: str, entity_tags: set[str], ui_url: str) -> str:
return "".join(parts)


def _show_diagram(readme: str, entity_tags: set[str] | None = None, ui_url: str = "https://app.getcortexapp.com") -> None:
_GITHUB_BLOB = "https://github.com/cortexapps/cli/blob/main/cortexapps_cli/solutions"


def _parse_tf_tags_by_type(solution_dir: Path) -> tuple[set[str], set[str]]:
"""Scan *.tf files under _templates/<solution>/ and return (entity_tags, scorecard_tags)."""
entity_tags: set[str] = set()
scorecard_tags: set[str] = set()
templates = next(
(d for d in (solution_dir / "_templates").iterdir() if d.is_dir() and not d.name.endswith("-delta")),
None,
) if (solution_dir / "_templates").exists() else None
if templates is None:
return entity_tags, scorecard_tags
for tf in templates.glob("*.tf"):
current_type: str | None = None
depth = 0
for line in tf.read_text(encoding="utf-8").split("\n"):
m = re.match(r'\s*resource\s+"(cortex_\w+)"\s+"[^"]+"\s*\{', line)
if m:
current_type = m.group(1)
depth = 1
continue
if current_type:
depth += line.count("{") - line.count("}")
if depth <= 0:
current_type = None
depth = 0
continue
tm = re.search(r'\btag\s*=\s*"([^"]+)"', line)
if tm:
if current_type == "cortex_catalog_entity":
entity_tags.add(tm.group(1))
elif current_type == "cortex_scorecard":
scorecard_tags.add(tm.group(1))
return entity_tags, scorecard_tags


def _extract_tf_entity_tags(solution_dir: Path) -> set[str]:
"""Return only cortex_catalog_entity tags from .tf template files."""
entity_tags, _ = _parse_tf_tags_by_type(solution_dir)
return entity_tags



def _apply_file_hyperlinks(line: str, solution_tag: str) -> str:
"""Replace bare filenames in the diagram with OSC 8 links to GitHub blob URLs."""
base = f"{_GITHUB_BLOB}/{solution_tag}/_templates/{solution_tag}"
parts = []
i = 0
while i < len(line):
# Look for a word boundary start: not alphanumeric/hyphen/dot before current pos
for filename in sorted(
(f for f in [
"teams.tf", "ecommerce.tf", "supply-chain.tf", "scorecards.tf",
"provider.tf", "variables.tf", "terraform.tfvars",
] if line.find(f, i) == i),
key=len, reverse=True,
):
after = i + len(filename)
# Ensure word boundary after: next char must not be alphanumeric
if after < len(line) and (line[after].isalnum() or line[after] in "-_."):
continue
parts.append(_osc8(f"{base}/{filename}", filename))
i = after
break
else:
parts.append(line[i])
i += 1
return "".join(parts)



def _show_diagram(
readme: str,
entity_tags: set[str] | None = None,
ui_url: str = "https://app.getcortexapp.com",
solution_tag: str = "",
) -> None:
block = _extract_first_codeblock(readme)
if not block:
return
Expand All @@ -577,23 +654,26 @@ def _show_diagram(readme: str, entity_tags: set[str] | None = None, ui_url: str

print()
for line in block.split("\n"):
if entity_tags and links_supported:
line = _apply_hyperlinks(line, entity_tags, ui_url)
if links_supported:
if entity_tags:
line = _apply_hyperlinks(line, entity_tags, ui_url)
if solution_tag:
line = _apply_file_hyperlinks(line, solution_tag)
# Use print() not console.print(): Rich counts OSC 8 escape bytes as
# visible characters, shifting ASCII art alignment.
print(f" {line}")

if entity_tags and not links_supported:
diagram_tags = sorted(
entity_rows = sorted(
(tag for tag in entity_tags if tag in block),
key=lambda t: t.lower(),
)
if diagram_tags:
if entity_rows:
print()
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2, 0, 0))
table.add_column("Entity")
table.add_column("URL")
for tag in diagram_tags:
for tag in entity_rows:
table.add_row(tag, f"{ui_url}/admin/resources?tag={tag}")
console.print(table)

Expand All @@ -612,18 +692,21 @@ def _post_install_menu(
import_report: str = "",
entity_tags: set[str] | None = None,
ui_url: str = "https://app.getcortexapp.com",
solution_tag: str = "",
) -> None:
options = [
("1", "Data Model"),
("2", "Next steps"),
("3", "Import report"),
("4", "Exit"),
]
actions = {
"1": lambda: _show_diagram(readme, entity_tags=entity_tags, ui_url=ui_url),
"1": lambda: _show_diagram(readme, entity_tags=entity_tags, ui_url=ui_url, solution_tag=solution_tag),
"2": lambda: _show_next_steps(readme),
"3": lambda: (console.print(), typer.echo(import_report)),
}
if import_report:
options.append(("3", "Import report"))
actions["3"] = lambda: (console.print(), typer.echo(import_report))
exit_key = str(len(options) + 1)
options.append((exit_key, "Exit"))

while True:
console.print()
Expand All @@ -634,7 +717,7 @@ def _post_install_menu(

choice = Prompt.ask("Choice", choices=[k for k, _ in options], show_choices=False)

if choice == "4":
if choice == exit_key:
break
actions[choice]()

Expand Down Expand Up @@ -731,9 +814,23 @@ def _do_import() -> None:
with as_file(root / solution) as sp:
resources = _collect_solution_resources(sp)
entity_tags = set(resources.get("catalog", []))
if solutions_dir:
entity_tags |= _extract_tf_entity_tags(root / solution)
else:
with as_file(root / solution) as sp:
entity_tags |= _extract_tf_entity_tags(sp)
except Exception:
pass
_post_install_menu(readme, import_report=output, entity_tags=entity_tags, ui_url=ui_url)
has_import_results = bool(
total_match and (int(total_match.group(1)) > 0 or int(total_match.group(2)) > 0)
)
_post_install_menu(
readme,
import_report=output if has_import_results else "",
entity_tags=entity_tags,
ui_url=ui_url,
solution_tag=solution,
)


@app.command(name="post-install")
Expand Down
2 changes: 1 addition & 1 deletion cortexapps_cli/solutions/harness-deploy/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def post_steps(self) -> None:
base_url = self._answers["cortex_base_url"].rstrip("/")
app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url
entity_tag = self._answers["entity_tag"]
cortex_url = f"{app_url}/admin/resources?tag={entity_tag}" # /admin/resources is the correct URL path (entity terminology in UI)
cortex_url = f"{app_url}/admin/resources?tag={entity_tag}"

harness_pipeline_url = (
f"{self._harness_base()}/ng/account/{self._harness_account()}"
Expand Down
Loading