Files
2026-07-13 12:55:23 +08:00

398 lines
14 KiB
Python

"""Render local matrix skill files with resolved CLI skill paths.
Installed layout (one directory per matrix, so the skill's relative links to
``references/*.md`` and ``scripts/*.py`` resolve):
~/.cli-hub/matrix/<name>/SKILL.md
~/.cli-hub/matrix/<name>/references/...
~/.cli-hub/matrix/<name>/scripts/...
Skill content source lookup chain:
1. Repo checkout: ``<repo_root>/cli-hub-matrix/<name>/`` (via ``skill_md``).
2. Bundled package data: ``cli_hub/_matrix_data/<name>/`` (shipped in
wheels/sdists built from a checkout; absent in editable installs, which
hit the checkout in step 1 instead).
3. Published URL: ``https://hkuds.github.io/CLI-Anything/matrix/<name>/SKILL.md``
(SKILL.md only; references/scripts stay remote and are linked from the
rendered file).
4. Generated stub.
"""
import shutil
import subprocess
from importlib import metadata
from pathlib import Path
import requests
from cli_hub.registry import get_cli
MATRIX_SKILL_DIR = Path.home() / ".cli-hub" / "matrix"
# Base URL where deploy-pages.yml publishes cli-hub-matrix/ content (main only).
MATRIX_CONTENT_BASE_URL = "https://hkuds.github.io/CLI-Anything/matrix"
# Package data dir bundled into wheels/sdists by cli-hub/setup.py.
BUNDLED_MATRIX_DATA_DIR = Path(__file__).resolve().parent / "_matrix_data"
# Asset directories co-installed beside the rendered SKILL.md.
MATRIX_ASSET_SUBDIRS = ("references", "scripts")
_COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
def _find_repo_root():
"""Find the repository root via git, falling back to parent traversal."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
root = Path(result.stdout.strip())
if root.is_dir():
return root
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Fallback: walk up from this file looking for .git
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / ".git").exists():
return parent
return None
def get_rendered_matrix_skill_path(name):
"""Return the local rendered SKILL.md path for a matrix.
Prefers the per-matrix directory layout (``<name>/SKILL.md``); falls back
to the legacy flat ``<name>.SKILL.md`` file when only that exists, so
pre-existing installs keep resolving until the next re-render.
"""
current = MATRIX_SKILL_DIR / name / "SKILL.md"
legacy = MATRIX_SKILL_DIR / f"{name}.SKILL.md"
if not current.exists() and legacy.exists():
return legacy
return current
def resolve_local_skill_path(cli):
"""Resolve an installed harness CLI's local SKILL.md path if possible."""
if cli.get("_source", "harness") != "harness":
return None
dist_name = cli.get("dist_name") or f"cli-anything-{cli['name']}"
try:
dist = metadata.distribution(dist_name)
except metadata.PackageNotFoundError:
return _fallback_repo_skill_path(cli)
for file in dist.files or []:
file_str = str(file).replace("\\", "/")
if file_str.endswith("/skills/SKILL.md") or file_str.endswith("skills/SKILL.md"):
return str(dist.locate_file(file).resolve())
return _fallback_repo_skill_path(cli)
def _fallback_repo_skill_path(cli):
"""Use the repo-relative skill path when available in the current checkout."""
skill_ref = cli.get("skill_md")
if not skill_ref or "://" in skill_ref or skill_ref.startswith("npx "):
return None
repo_root = _find_repo_root()
if repo_root is None:
return None
candidate = repo_root / skill_ref
if candidate.exists():
return str(candidate.resolve())
return None
def render_matrix_skill_file(matrix_item, installed=None):
"""Write a local matrix SKILL.md with resolved member skill paths.
Renders into ``MATRIX_SKILL_DIR/<name>/SKILL.md`` and co-installs the
matrix content directory's ``references/`` and ``scripts/`` beside it so
the skill's relative links resolve. Re-rendering is idempotent: asset
directories are replaced wholesale on each render.
"""
name = matrix_item["name"]
output_dir = MATRIX_SKILL_DIR / name
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "SKILL.md"
template_path, content_dir = _resolve_matrix_content_source(matrix_item)
base_content = _load_matrix_skill_template(matrix_item, template_path).rstrip()
copied = _copy_matrix_assets(content_dir, output_dir) if content_dir else []
extra = ""
if not copied:
extra = (
"\n\n## Reference Modules\n\n"
"No local copy of this matrix's `references/` and `scripts/` was "
"found; relative links above will not resolve locally. The "
"published copies live under "
f"{MATRIX_CONTENT_BASE_URL}/{name}/ (e.g. "
f"{MATRIX_CONTENT_BASE_URL}/{name}/SKILL.md)."
)
injected_section = _render_injected_section(matrix_item, installed or {})
output_path.write_text(
f"{base_content}\n\n<!-- MATRIX_SKILL_PATHS:START -->\n\n{injected_section}{extra}\n\n<!-- MATRIX_SKILL_PATHS:END -->\n",
encoding="utf-8",
)
return output_path
def _resolve_matrix_content_source(matrix_item):
"""Locate the matrix skill source as ``(template_path, content_dir)``.
Tries the repo checkout first, then bundled package data. Either element
may be ``None`` when nothing local is available (the template then falls
back to the published URL or a stub).
"""
skill_ref = matrix_item.get("skill_md")
if skill_ref and "://" not in skill_ref and not skill_ref.startswith("npx "):
repo_root = _find_repo_root()
if repo_root is not None:
candidate = repo_root / skill_ref
if candidate.exists():
return candidate, candidate.parent
bundled = BUNDLED_MATRIX_DATA_DIR / matrix_item["name"] / "SKILL.md"
if bundled.exists():
return bundled, bundled.parent
return None, None
def _copy_matrix_assets(content_dir, output_dir):
"""Copy references/ and scripts/ beside the rendered SKILL.md.
Existing asset directories are removed first so re-installs are clean and
stale files do not linger. ``__pycache__`` and ``*.pyc`` are excluded.
Returns the list of subdirectories that were copied.
"""
copied = []
for subdir in MATRIX_ASSET_SUBDIRS:
source = content_dir / subdir
destination = output_dir / subdir
if destination.exists():
shutil.rmtree(destination)
if source.is_dir():
shutil.copytree(source, destination, ignore=_COPY_IGNORE)
copied.append(subdir)
return copied
def _load_matrix_skill_template(matrix_item, template_path=None):
"""Load the matrix skill template via the lookup chain.
Order: local source file (checkout or bundled data) -> published URL ->
generated stub.
"""
if template_path is None:
template_path, _ = _resolve_matrix_content_source(matrix_item)
if template_path is not None:
return template_path.read_text(encoding="utf-8")
published = _fetch_published_matrix_skill(matrix_item["name"])
if published is not None:
return published
title = matrix_item.get("display_name", matrix_item["name"])
description = matrix_item.get("description", "")
return (
f"# {title}\n\n"
f"{description}\n\n"
f"Install with `cli-hub matrix install {matrix_item['name']}`.\n\n"
f"Full skill content (when published): "
f"{MATRIX_CONTENT_BASE_URL}/{matrix_item['name']}/SKILL.md"
)
def _fetch_published_matrix_skill(name):
"""Fetch the published SKILL.md for a matrix, or None on any failure."""
url = f"{MATRIX_CONTENT_BASE_URL}/{name}/SKILL.md"
try:
resp = requests.get(url, timeout=10)
except requests.RequestException:
return None
if resp.status_code != 200 or not resp.text.strip():
return None
return resp.text
def _render_injected_section(matrix_item, installed):
"""Render the injected skill reference section."""
lines = [
"## Installed CLI Skills",
"",
"Generated by `cli-hub matrix install` from the current local environment.",
"",
"| CLI | Entry Point | Canonical Skill | Local Skill | Status |",
"|---|---|---|---|---|",
]
for cli_name in matrix_item.get("clis", []):
cli = get_cli(cli_name) or {"name": cli_name, "entry_point": cli_name}
canonical_skill = cli.get("skill_md") or "—"
local_skill = resolve_local_skill_path(cli) or "—"
status = "installed" if cli_name in installed else "not installed"
lines.append(
f"| `{cli_name}` | `{cli.get('entry_point', '—')}` | "
f"{canonical_skill} | {local_skill} | {status} |"
)
capability_tooling = _render_capability_tooling(matrix_item, installed)
if capability_tooling:
lines.append("")
lines.append(capability_tooling)
stage_tooling = _render_stage_tooling(matrix_item, installed)
if stage_tooling:
lines.append("")
lines.append(stage_tooling)
discovery = _render_discovery_section(matrix_item)
if discovery:
lines.append("")
lines.append(discovery)
return "\n".join(lines)
def _provider_installed(provider, installed):
"""Return whether a CLI provider appears installed by cli-hub name."""
if provider.get("kind") not in {"harness-cli", "public-cli"}:
return False
name = provider.get("name", "")
aliases = {name}
if name.startswith("cli-anything-"):
aliases.add(name.removeprefix("cli-anything-"))
return any(alias in installed for alias in aliases)
def _format_requires(provider):
requires = provider.get("requires") or {}
parts = []
for key in ("binary", "env", "package"):
values = requires.get(key) or []
if isinstance(values, str):
values = [values]
if values:
parts.append(f"{key}: {', '.join(values)}")
return "; ".join(parts) if parts else "none"
def _render_capability_tooling(matrix_item, installed):
"""Render v2 capability/provider guidance for local matrix skills."""
capabilities = matrix_item.get("capabilities", [])
if not capabilities:
return ""
lines = [
"## Capability Provider Overview",
"",
"Pick providers per capability from task constraints and preflight facts. CLI providers show cli-hub install status; non-CLI providers list their preflight requirements.",
"",
]
for capability in capabilities:
lines.append(f"### `{capability['id']}`")
if capability.get("intent"):
lines.append(capability["intent"])
lines.append("")
for provider in capability.get("providers", []):
kind = provider.get("kind", "provider")
quality = provider.get("quality_tier", "unknown")
cost = provider.get("cost_tier", "unknown")
offline = "offline" if provider.get("offline") else "online"
status = ""
if kind in {"harness-cli", "public-cli"}:
status = "installed" if _provider_installed(provider, installed) else "not installed"
status = f"; {status}"
requires = _format_requires(provider)
lines.append(
f"- `{provider.get('name', '')}` ({kind}; {quality}; {cost}; {offline}{status})"
)
lines.append(f" - Requires: {requires}")
if provider.get("notes"):
lines.append(f" - Notes: {provider['notes']}")
lines.append("")
recipes = matrix_item.get("recipes", [])
if recipes:
lines.append("## Recipes")
lines.append("")
for recipe in recipes:
capabilities_used = ", ".join(f"`{item}`" for item in recipe.get("capabilities_used", []))
lines.append(f"- `{recipe['id']}`: {recipe.get('description', '')}")
if capabilities_used:
lines.append(f" - Uses: {capabilities_used}")
lines.append("")
known_gaps = matrix_item.get("known_gaps", [])
if known_gaps:
lines.append("## Known Gaps")
lines.append("")
for gap in known_gaps:
lines.append(f"- `{gap.get('capability', 'unknown')}`: {gap.get('reason', '')}")
if gap.get("workaround"):
lines.append(f" - Workaround: {gap['workaround']}")
lines.append("")
return "\n".join(lines).rstrip()
def _render_stage_tooling(matrix_item, installed):
"""Render per-stage tooling overview with goals and alternatives."""
stages = matrix_item.get("stages", [])
has_goals = any(s.get("goal") for s in stages)
if not has_goals:
return ""
lines = [
"## Stage Tooling Overview",
"",
"What is available for each stage on this system.",
"",
]
for stage in stages:
goal = stage.get("goal")
if not goal:
continue
lines.append(f"### {stage['name']}")
lines.append(f"**Goal:** {goal}")
lines.append("")
for cli_name in stage.get("clis", []):
marker = "installed" if cli_name in installed else "not installed"
lines.append(f"- CLI: `{cli_name}` ({marker})")
alts = stage.get("alternatives", {})
if alts.get("python"):
lines.append(f"- Python: {', '.join(alts['python'])}")
if alts.get("api"):
lines.append(f"- APIs: {', '.join(alts['api'])}")
if alts.get("native"):
lines.append(f"- Native: {', '.join(alts['native'])}")
lines.append("")
return "\n".join(lines)
def _render_discovery_section(matrix_item):
"""Registry search hints are metadata; generated skills list concrete providers."""
return ""