Files
Yuhao 5b9ec80def Feature/cli matrix multi approach (#355)
* feat: CLI-Matrix with multi-approach stages, skill discovery, and matrix search

Introduce CLI-Matrix — curated multi-CLI workflow matrices that agents can
install in one command. The video-creation matrix bundles 11 CLIs across 8
production stages (AI video gen, capture, audio, voice/TTS, music, NLE
editing, captions, thumbnails).

Each stage now exposes a goal, alternative approaches (Python libs, cloud
APIs, native commands), and skill_search_hints that encourage agents to
dynamically discover relevant skills via `npx skills search` rather than
relying on hard-coded tool lists.

Key changes:
- matrix_registry.json: extended stage schema with goal, alternatives,
  skill_search_hints fields
- cli-hub matrix list/search/info/install commands
- matrix_skill.py: renders dynamic SKILL.md with stage tooling overview,
  install status, and aggregated discovery commands
- Fixed brittle parents[2] repo root detection with git-based lookup
- 85 tests passing (10 new)


* update cli-matrix

* feat(cli-matrix): eco-first capability-based matrix, v2 schema + S2-S5 SKILLs

- Add docs/cli-matrix/matrix_registry.schema.md describing v2 capability-based
  registry shape (capabilities[], providers with kind/requires/cost/quality/
  offline, recipes[], known_gaps[], decision rubric, suggest-to-user template).
- Rewrite cli-hub-matrix/video-creation/SKILL.md and matrix_registry.json (S1)
  around capabilities + providers + recipes instead of linear stages.
- Rename Vn -> Sn across cli-matrix-plan.md and test fixtures.
- Reorder scenarios by current completeness; rewrite S2 knowledge-research,
  S3 3d-cad, S4 game-development, S5 image-design in v2 capability form with
  full SKILL.md files.
- Add docs/cli-matrix/test-plans/video-creation.md with 13 long realistic
  end-to-end tasks as checkable todo lists, each exercising 5-9 capabilities.
- Move cli-matrix-plan.md and matrix_registry.schema.md under docs/cli-matrix/.


* Document preview protocol and Audacity autosave

Add the preview bundle protocol plan, record video matrix review evidence, and make one-shot Audacity project mutations persist to disk with E2E coverage.

* Update CLI matrix skill registry and video workflow

* chore(git): always ignore docs/* — working documents stay local


* feat(cli-matrix): video-creation skill WIP — sound design, source triage, render doctor, NLE refs, video_doctor script

- SKILL.md: adds sound.design capability, bundled video_doctor.py provider,
  recipe updates, and links to five new reference modules
- new references: art-direction-review, nle-shotcut-kdenlive, render-doctor,
  sound-design, source-triage; captions and story-structure-audio updated
- scripts/video_doctor.py: bundled probe/diagnose helper


* fix(cli-hub): preflight detects packages by import name or PyPI dist name (P1-3)

_package_available() now tries find_spec as-is, dash->underscore
normalized, then importlib.metadata dist lookup (PEP 503), so registry
entries like edge-tts are detected when installed. All lookup failures
degrade to unavailable instead of crashing preflight. Adds 6 tests.


* feat(cli-hub): distribute matrix skill content to installed skills, wheels, and Pages (P1-4)

- matrix install renders to ~/.cli-hub/matrix/<name>/SKILL.md and copies
  references/ and scripts/ beside it (pycache excluded, idempotent
  reinstall purges stale files); legacy flat <name>.SKILL.md still read
- content lookup chain: repo checkout -> bundled cli_hub/_matrix_data
  (vendored into sdist/wheel by setup.py build hooks + MANIFEST.in) ->
  published Pages URL -> stub
- new 'matrix install --skill-only' renders skill + assets without
  installing CLIs
- deploy-pages.yml: copy cli-hub-matrix/ into the site after the Jekyll
  build (served verbatim at /matrix/<name>/); triggers remain main-only
- 12 new tests in tests/test_matrix_skill_dist.py (142 total pass)


* feat(cli-matrix): register S2-S5 matrices and resync video-creation registry (P1-1, P1-2)

- add knowledge-research (S2, 12 caps), 3d-cad (S3, 12), game-development
  (S4, 10), image-design (S5, 9) derived from their SKILL.md drafts; full
  v2 shape with capabilities, providers, recipes, known_gaps; clis lists
  cross-checked against registry.json (unresolvable tools represented as
  public-cli/native/python/api/agent-skill providers instead)
- video-creation: add sound.design capability (5 providers, wired into 5
  recipes), register scripts/video_doctor.py as bundled-script provider
  under quality.review, cite the 5 new reference modules in provider
  notes, refresh description
- python provider package strings use import names (cv2, edge_tts,
  ffmpeg, skimage, ...) so preflight detection is robust to dist-name
  variants like opencv-python-headless
- fix homepage URLs to docs/cli-matrix/cli-matrix-plan.md; bump
  meta.updated to 2026-06-11


* feat(cli-hub): ship the unified Gallery design as the production homepage

Replace docs/hub/index.html with the finalized "Gallery / R2 Flip" main page
(Steel Sky palette default, Newsreader serif hero title, liquid-glass flip
cards, JS masonry catalog, and the unified Matrices layer with bidirectional
stitching). Production-indexable robots meta retained.

Stop tracking docs/cli-matrix/* — the CLI-Matrix working docs stay local and
confidential; add an explicit /docs/cli-matrix/ ignore rule.

* feat: CLI-Matrix command family + Hub docs/demos pages and responsive nav

---------
2026-06-14 17:27:52 +08:00

538 lines
20 KiB
Python

"""Fetch, cache, and query curated CLI workflow matrices."""
import importlib.metadata
import importlib.util
import json
import os
import shutil
import time
from pathlib import Path
import requests
MATRIX_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/matrix_registry.json"
MATRIX_CACHE_FILE = Path.home() / ".cli-hub" / "matrix_registry_cache.json"
CACHE_TTL = 3600 # 1 hour
AGENT_INSTALLABLE_KINDS = {"agent-skill"}
# Provider kinds whose CLIs `matrix install` can manage via cli-hub / public registries.
INSTALLABLE_KINDS = {"harness-cli", "public-cli"}
# Harness CLI providers are named `cli-anything-<cli>`; the flat `clis[]` list uses `<cli>`.
HARNESS_PREFIX = "cli-anything-"
# Short, stable labels for provider kinds used across search / can / preflight output.
KIND_LABELS = {
"harness-cli": "harness",
"public-cli": "public",
"python": "python",
"native": "native",
"api": "api",
"agent-skill": "skill",
"agent-native": "native",
"web-search": "web",
}
def _ensure_cache_dir():
MATRIX_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
def _load_cached_data():
if not MATRIX_CACHE_FILE.exists():
return None
try:
cached = json.loads(MATRIX_CACHE_FILE.read_text())
return cached["data"]
except (json.JSONDecodeError, KeyError):
return None
def _load_local_registry():
"""Load matrix_registry.json from a source checkout when available."""
for parent in Path(__file__).resolve().parents:
candidate = parent / "matrix_registry.json"
if not candidate.exists():
continue
try:
return json.loads(candidate.read_text())
except json.JSONDecodeError:
return None
return None
def fetch_matrix_registry(force_refresh=False):
"""Fetch the matrix registry with local file caching."""
_ensure_cache_dir()
if not force_refresh and MATRIX_CACHE_FILE.exists():
try:
cached = json.loads(MATRIX_CACHE_FILE.read_text())
if time.time() - cached.get("_cached_at", 0) < CACHE_TTL:
return cached["data"]
except (json.JSONDecodeError, KeyError):
pass
try:
resp = requests.get(MATRIX_REGISTRY_URL, timeout=15)
resp.raise_for_status()
data = resp.json()
except (requests.RequestException, ValueError):
cached_data = _load_cached_data()
if cached_data is not None:
return cached_data
local_data = _load_local_registry()
if local_data is not None:
return local_data
raise
MATRIX_CACHE_FILE.write_text(json.dumps({"_cached_at": time.time(), "data": data}, indent=2))
return data
def fetch_all_matrices(force_refresh=False):
"""Return all matrix entries."""
return fetch_matrix_registry(force_refresh).get("matrices", [])
def get_matrix(name, force_refresh=False):
"""Look up a matrix entry by name (case-insensitive)."""
name_lower = name.lower()
for matrix_item in fetch_all_matrices(force_refresh):
if matrix_item["name"].lower() == name_lower:
return matrix_item
return None
def _string_values(value):
"""Yield lowercase strings from nested matrix registry values."""
if isinstance(value, str):
yield value.lower()
elif isinstance(value, dict):
for child in value.values():
yield from _string_values(child)
elif isinstance(value, list):
for child in value:
yield from _string_values(child)
def search_matrices(query, force_refresh=False):
"""Search matrices by name, capabilities, providers, recipes, or gaps."""
query_lower = query.lower()
results = []
for matrix_item in fetch_all_matrices(force_refresh):
haystack_values = {
"name": matrix_item.get("name", ""),
"display_name": matrix_item.get("display_name", ""),
"description": matrix_item.get("description", ""),
"category": matrix_item.get("category", ""),
"matrix_id": matrix_item.get("matrix_id", ""),
"capabilities": matrix_item.get("capabilities", []),
"recipes": matrix_item.get("recipes", []),
"known_gaps": matrix_item.get("known_gaps", []),
"clis": matrix_item.get("clis", []),
}
if any(query_lower in value for value in _string_values(haystack_values)):
results.append(matrix_item)
return results
def _as_list(value):
"""Normalize registry requirement fields to lists."""
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _package_available(name):
try:
if importlib.util.find_spec(name) is not None:
return True
except Exception:
pass
try:
normalized = name.replace("-", "_")
if normalized != name and importlib.util.find_spec(normalized) is not None:
return True
except Exception:
pass
try:
importlib.metadata.version(name)
return True
except Exception:
pass
return False
def check_provider_requirements(provider):
"""Check whether a provider's declared requirements are available locally."""
kind = provider.get("kind", "")
if kind in AGENT_INSTALLABLE_KINDS:
return {
"name": provider.get("name", ""),
"kind": kind,
"available": False,
"agent_installable": True,
"status": "agent-installable",
"offline": bool(provider.get("offline")),
"cost_tier": provider.get("cost_tier", "unknown"),
"quality_tier": provider.get("quality_tier", "unknown"),
"requires": provider.get("requires") or {},
"present": {"env": [], "binary": [], "package": []},
"missing": {"env": [], "binary": [], "package": []},
"notes": provider.get("notes", ""),
"install_hint": provider.get("install_hint"),
}
requires = provider.get("requires") or {}
env_names = _as_list(requires.get("env"))
binary_names = _as_list(requires.get("binary"))
package_names = _as_list(requires.get("package"))
present = {
"env": [name for name in env_names if os.environ.get(name)],
"binary": [name for name in binary_names if shutil.which(name)],
"package": [name for name in package_names if _package_available(name)],
}
missing = {
"env": [name for name in env_names if name not in present["env"]],
"binary": [name for name in binary_names if name not in present["binary"]],
"package": [name for name in package_names if name not in present["package"]],
}
available = not any(missing.values())
return {
"name": provider.get("name", ""),
"kind": kind,
"available": available,
"agent_installable": False,
"status": "available" if available else "missing",
"offline": bool(provider.get("offline")),
"cost_tier": provider.get("cost_tier", "unknown"),
"quality_tier": provider.get("quality_tier", "unknown"),
"requires": requires,
"present": present,
"missing": missing,
"notes": provider.get("notes", ""),
"install_hint": provider.get("install_hint"),
}
def preflight_matrix(matrix_item, capability_id=None, offline=False, capability_ids=None):
"""Return provider availability for a matrix, optionally filtered.
``capability_id`` filters to a single capability; ``capability_ids`` (a set or
list, used by ``--recipe``) filters to a named subset. The two compose.
"""
id_filter = set(capability_ids) if capability_ids is not None else None
capability_results = []
for capability in matrix_item.get("capabilities", []):
if capability_id and capability.get("id") != capability_id:
continue
if id_filter is not None and capability.get("id") not in id_filter:
continue
provider_results = [
check_provider_requirements(provider)
for provider in capability.get("providers", [])
if not offline or provider.get("offline")
]
capability_results.append({
"id": capability.get("id", ""),
"intent": capability.get("intent", ""),
"provider_count": len(provider_results),
"available_count": sum(1 for provider in provider_results if provider["available"]),
"agent_installable_count": sum(
1 for provider in provider_results
if provider.get("agent_installable")
),
"providers": provider_results,
})
summary = {
"capabilities": len(capability_results),
"with_available_provider": sum(1 for cap in capability_results if cap["available_count"] > 0),
"with_agent_installable_provider": sum(
1 for cap in capability_results
if cap["available_count"] == 0 and cap["agent_installable_count"] > 0
),
"providers": sum(cap["provider_count"] for cap in capability_results),
"available_providers": sum(cap["available_count"] for cap in capability_results),
"agent_installable_providers": sum(
cap["agent_installable_count"] for cap in capability_results
),
}
# A capability is "covered" when it has at least one available provider or an
# agent-installable fallback; everything else is a hard gap (drives exit code 3).
summary["covered"] = sum(
1 for cap in capability_results
if cap["available_count"] > 0 or cap["agent_installable_count"] > 0
)
summary["gaps"] = summary["capabilities"] - summary["covered"]
return {
"matrix": {
"name": matrix_item.get("name", ""),
"display_name": matrix_item.get("display_name", matrix_item.get("name", "")),
"schema_version": matrix_item.get("schema_version", "1"),
},
"capability_filter": capability_id,
"offline": offline,
"summary": summary,
"capabilities": capability_results,
}
# ── Provider ↔ CLI resolution and install scoping (F2.2) ──────────────────────
def provider_cli_name(provider, cli_names):
"""Resolve a provider to the ``clis[]`` member that ``matrix install`` manages.
Returns the registry CLI name, or ``None`` when the provider is not installable
through cli-hub (Python libs, native binaries, cloud APIs, agent skills, or
third-party public CLIs that live outside the matrix's ``clis[]`` list).
"""
if provider.get("kind") not in INSTALLABLE_KINDS:
return None
cli_set = set(cli_names)
explicit = provider.get("cli") # forward-compatible with schema v2.1 (F4.2)
if explicit and explicit in cli_set:
return explicit
name = provider.get("name", "")
if name in cli_set:
return name
if name.startswith(HARNESS_PREFIX):
stripped = name[len(HARNESS_PREFIX):]
if stripped in cli_set:
return stripped
return None
def provider_install_hint(provider, cli_names):
"""Return a human-readable install command for a provider, or ``None``.
Prefers the registry's explicit ``install_hint`` (F2.4); otherwise derives
``cli-hub install <cli>`` for providers that map into the matrix's ``clis[]``.
"""
hint = provider.get("install_hint")
if hint:
return hint
cli = provider_cli_name(provider, cli_names)
if cli:
return f"cli-hub install {cli}"
return None
def get_recipe(matrix_item, recipe_id):
"""Look up a recipe entry by id (case-insensitive)."""
recipe_lower = recipe_id.lower()
for recipe in matrix_item.get("recipes", []):
if recipe.get("id", "").lower() == recipe_lower:
return recipe
return None
def _scope_clis_for_capabilities(matrix_item, capabilities):
"""Return the ``clis[]`` members backing the given capabilities, in registry order."""
cli_names = matrix_item.get("clis", [])
wanted = set()
for capability in capabilities:
for provider in capability.get("providers", []):
cli = provider_cli_name(provider, cli_names)
if cli:
wanted.add(cli)
return [name for name in cli_names if name in wanted]
def resolve_install_scope(matrix_item, capability=None, recipe=None, only=None):
"""Resolve install scope flags to a concrete subset of the matrix's ``clis[]``.
Returns a dict with ``cli_names`` (ordered subset), ``scope`` (type/value),
``capabilities`` (capability ids in scope, when applicable), and ``error``
(a usage message when the selectors are invalid or mutually exclusive).
"""
cli_names = matrix_item.get("clis", [])
selectors = [(kind, value) for kind, value in
(("capability", capability), ("recipe", recipe), ("only", only)) if value]
if len(selectors) > 1:
return {"error": "Use only one of --capability, --recipe, or --only.",
"scope": {"type": "invalid"}, "cli_names": [], "capabilities": []}
if not selectors:
return {"error": None, "scope": {"type": "all"}, "cli_names": list(cli_names),
"capabilities": [c.get("id") for c in matrix_item.get("capabilities", [])]}
sel_type, sel_value = selectors[0]
if sel_type == "only":
requested = [name.strip() for name in only.split(",") if name.strip()]
unknown = [name for name in requested if name not in set(cli_names)]
if unknown:
return {"error": (f"Not in matrix '{matrix_item.get('name')}' clis[]: "
f"{', '.join(unknown)}. Valid: {', '.join(cli_names) or '(none)'}"),
"scope": {"type": "only"}, "cli_names": [], "capabilities": []}
chosen = [name for name in cli_names if name in set(requested)]
return {"error": None, "scope": {"type": "only", "value": requested},
"cli_names": chosen, "capabilities": []}
if sel_type == "capability":
capability_item = next(
(c for c in matrix_item.get("capabilities", []) if c.get("id") == capability), None)
if capability_item is None:
valid = ", ".join(c.get("id", "") for c in matrix_item.get("capabilities", []))
return {"error": (f"Capability '{capability}' not found in "
f"'{matrix_item.get('name')}'. Valid: {valid or '(none)'}"),
"scope": {"type": "capability"}, "cli_names": [], "capabilities": []}
return {"error": None, "scope": {"type": "capability", "value": capability},
"cli_names": _scope_clis_for_capabilities(matrix_item, [capability_item]),
"capabilities": [capability]}
# sel_type == "recipe"
recipe_item = get_recipe(matrix_item, recipe)
if recipe_item is None:
valid = ", ".join(r.get("id", "") for r in matrix_item.get("recipes", []))
return {"error": (f"Recipe '{recipe}' not found in '{matrix_item.get('name')}'. "
f"Valid: {valid or '(none)'}"),
"scope": {"type": "recipe"}, "cli_names": [], "capabilities": []}
used = recipe_item.get("capabilities_used", [])
used_set = set(used)
capabilities = [c for c in matrix_item.get("capabilities", []) if c.get("id") in used_set]
return {"error": None, "scope": {"type": "recipe", "value": recipe},
"cli_names": _scope_clis_for_capabilities(matrix_item, capabilities),
"capabilities": list(used)}
def unmanaged_providers(matrix_item, capabilities=None):
"""Group providers that ``matrix install`` does NOT install, by category.
Used by ``install --dry-run`` to show what still needs manual setup
(Python libs, native binaries, cloud APIs, agent skills, and third-party
public CLIs outside ``clis[]``).
"""
cli_names = matrix_item.get("clis", [])
caps = capabilities if capabilities is not None else matrix_item.get("capabilities", [])
buckets = {"python": [], "native": [], "api": [], "agent-skill": [], "public-unmanaged": []}
seen = set()
for capability in caps:
for provider in capability.get("providers", []):
kind = provider.get("kind")
name = provider.get("name", "")
key = (kind, name)
if key in seen:
continue
if kind in INSTALLABLE_KINDS:
if kind == "public-cli" and provider_cli_name(provider, cli_names) is None:
buckets["public-unmanaged"].append(name)
seen.add(key)
continue
if kind in buckets:
buckets[kind].append(name)
seen.add(key)
return {category: names for category, names in buckets.items() if names}
# ── Capability-level search (F1.1 / F1.4) ─────────────────────────────────────
def providers_summary(capability, limit=4):
"""Render a compact 'name (kind) · …' summary of a capability's providers."""
providers = capability.get("providers", [])
parts = [
f"{p.get('name', '')} ({KIND_LABELS.get(p.get('kind'), p.get('kind', '?'))})"
for p in providers[:limit]
]
summary = " · ".join(parts)
extra = len(providers) - limit
if extra > 0:
summary += f" · +{extra} more"
return summary
def _capability_match_field(capability, query_lower):
"""Return which field of a capability matched the query, or ``None``."""
if query_lower in capability.get("id", "").lower():
return "id"
if query_lower in capability.get("intent", "").lower():
return "intent"
if any(query_lower in hint.lower() for hint in capability.get("skill_search_hints", [])):
return "hint"
for provider in capability.get("providers", []):
if query_lower in provider.get("name", "").lower():
return "provider"
return None
def capability_matches(matrix_item, query_lower):
"""Return per-capability match attribution for a single matrix (F1.1 matched_in)."""
matches = []
for capability in matrix_item.get("capabilities", []):
field = _capability_match_field(capability, query_lower)
if not field:
continue
matches.append({
"matrix": matrix_item.get("name", ""),
"matrix_id": matrix_item.get("matrix_id", ""),
"capability_id": capability.get("id", ""),
"intent": capability.get("intent", ""),
"match_field": field,
"providers_summary": providers_summary(capability),
})
return matches
def search_capabilities(query, force_refresh=False):
"""Search every matrix at capability granularity (powers ``cli-hub can``).
Each hit carries local provider availability so callers can show what is
usable on this machine right now.
"""
query_lower = query.lower()
hits = []
for matrix_item in fetch_all_matrices(force_refresh):
for capability in matrix_item.get("capabilities", []):
field = _capability_match_field(capability, query_lower)
if not field:
continue
hits.append({
"matrix": matrix_item.get("name", ""),
"matrix_id": matrix_item.get("matrix_id", ""),
"capability_id": capability.get("id", ""),
"intent": capability.get("intent", ""),
"match_field": field,
"providers": [
check_provider_requirements(provider)
for provider in capability.get("providers", [])
],
})
return hits
def all_recipes(query=None, force_refresh=False):
"""Return recipes across all matrices, optionally filtered by a query (F1.4)."""
query_lower = query.lower() if query else None
out = []
for matrix_item in fetch_all_matrices(force_refresh):
for recipe in matrix_item.get("recipes", []):
if query_lower:
haystack = " ".join([
recipe.get("id", ""),
recipe.get("description", ""),
" ".join(recipe.get("capabilities_used", [])),
]).lower()
if query_lower not in haystack:
continue
out.append({
"matrix": matrix_item.get("name", ""),
"matrix_id": matrix_item.get("matrix_id", ""),
"id": recipe.get("id", ""),
"description": recipe.get("description", ""),
"capabilities_used": recipe.get("capabilities_used", []),
})
return out