mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-31 02:42:19 +08:00
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) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate cli-hub-skill/SKILL.md from registry.json and public_registry.json."""
|
||||
"""Generate cli-hub-skill/SKILL.md from registry.json, public_registry.json, and matrix_registry.json."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
@@ -8,6 +8,7 @@ def main():
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
registry_path = repo_root / 'registry.json'
|
||||
public_registry_path = repo_root / 'public_registry.json'
|
||||
matrix_registry_path = repo_root / 'matrix_registry.json'
|
||||
output_path = repo_root / 'cli-hub-skill' / 'SKILL.md'
|
||||
|
||||
with open(registry_path) as f:
|
||||
@@ -19,6 +20,12 @@ def main():
|
||||
public_data = json.load(f)
|
||||
public_clis = public_data.get('clis', [])
|
||||
|
||||
matrices = []
|
||||
if matrix_registry_path.exists():
|
||||
with open(matrix_registry_path) as f:
|
||||
matrix_data = json.load(f)
|
||||
matrices = matrix_data.get('matrices', [])
|
||||
|
||||
total_count = len(data['clis']) + len(public_clis)
|
||||
|
||||
# Group harness CLIs by category
|
||||
@@ -65,6 +72,21 @@ def main():
|
||||
"cli-hub launch <name> [args...]",
|
||||
"```",
|
||||
"",
|
||||
"## CLI Matrices",
|
||||
"",
|
||||
f"`cli-hub` also ships {len(matrices)} curated cross-tool matrices: install one name to pull in a whole workflow kit and read its dedicated SKILL.md.",
|
||||
"",
|
||||
"```bash",
|
||||
"# Browse curated matrices",
|
||||
"cli-hub matrix list",
|
||||
"",
|
||||
"# Inspect one matrix",
|
||||
"cli-hub matrix info video-creation",
|
||||
"",
|
||||
"# Install the whole matrix",
|
||||
"cli-hub matrix install video-creation",
|
||||
"```",
|
||||
"",
|
||||
"## CLI-Anything Harness CLIs",
|
||||
"",
|
||||
f"Stateful, agent-native wrappers for {len(data['clis'])} GUI applications. All support `--json` output, REPL mode, and undo/redo.",
|
||||
@@ -97,15 +119,36 @@ def main():
|
||||
clis = public_by_category[category]
|
||||
lines.append(f"### {category.title()}")
|
||||
lines.append("")
|
||||
lines.append("| Name | Description | Entry Point | Install |")
|
||||
lines.append("|------|-------------|-------------|---------|")
|
||||
lines.append("| Name | Description | Entry Point | Install | Skill |")
|
||||
lines.append("|------|-------------|-------------|---------|-------|")
|
||||
|
||||
for cli in sorted(clis, key=lambda x: x['name']):
|
||||
name = cli['display_name']
|
||||
desc = cli['description']
|
||||
entry = f"`{cli['entry_point']}`"
|
||||
install = f"`cli-hub install {cli['name']}`"
|
||||
lines.append(f"| **{name}** | {desc} | {entry} | {install} |")
|
||||
skill = cli.get('skill_md') or '—'
|
||||
skill_cell = f"`{skill}`" if not str(skill).startswith("http") else skill
|
||||
lines.append(f"| **{name}** | {desc} | {entry} | {install} | {skill_cell} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
if matrices:
|
||||
lines.extend([
|
||||
"## Curated Matrices",
|
||||
"",
|
||||
"Each matrix is a curated multi-CLI workflow pulled from the CLI Matrix. Installing a matrix installs all member CLIs and points you at a matrix-specific SKILL.md.",
|
||||
"",
|
||||
"| Matrix | Description | CLIs | Install | Skill |",
|
||||
"|--------|-------------|------|---------|-------|",
|
||||
])
|
||||
|
||||
for matrix in sorted(matrices, key=lambda x: x['name']):
|
||||
skill = matrix.get('skill_md') or '—'
|
||||
install = f"`cli-hub matrix install {matrix['name']}`"
|
||||
lines.append(
|
||||
f"| **{matrix['display_name']}** | {matrix['description']} | {len(matrix.get('clis', []))} | {install} | `{skill}` |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
|
||||
@@ -119,6 +162,7 @@ def main():
|
||||
"- **uv CLIs**: installed via `uv tool install`",
|
||||
"- **brew/script CLIs**: installed via the tool's native installer",
|
||||
"- **bundled CLIs**: detected from PATH (pre-installed with the host app)",
|
||||
"- **Matrices**: install a curated set of harness and public CLIs in one command",
|
||||
"",
|
||||
"## Harness CLI Usage Pattern",
|
||||
"",
|
||||
@@ -152,7 +196,10 @@ def main():
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text('\n'.join(lines) + '\n')
|
||||
print(f"Generated meta-skill with {len(data['clis'])} harness CLIs + {len(public_clis)} public CLIs ({total_count} total) at {output_path}")
|
||||
print(
|
||||
f"Generated meta-skill with {len(data['clis'])} harness CLIs + "
|
||||
f"{len(public_clis)} public CLIs + {len(matrices)} matrices at {output_path}"
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -8,7 +8,9 @@ on:
|
||||
- '*/agent-harness/**'
|
||||
- 'registry.json'
|
||||
- 'public_registry.json'
|
||||
- 'matrix_registry.json'
|
||||
- 'cli-hub/**'
|
||||
- 'cli-hub-matrix/**'
|
||||
- 'docs/hub/**'
|
||||
- '.github/workflows/deploy-pages.yml'
|
||||
- '.github/scripts/update_registry_dates.py'
|
||||
@@ -61,6 +63,9 @@ jobs:
|
||||
- name: Copy registry.json to hub for cli-hub access
|
||||
run: cp registry.json docs/hub/registry.json
|
||||
|
||||
- name: Copy matrix_registry.json to hub for cli-hub access
|
||||
run: cp matrix_registry.json docs/hub/matrix_registry.json
|
||||
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: cli-hub-matrix-video-creation
|
||||
description: >-
|
||||
Curated multi-CLI matrix for generating, editing, captioning, and packaging
|
||||
videos across CLI-Anything harnesses, public CLIs, Python libraries, and cloud APIs.
|
||||
---
|
||||
|
||||
# Video Creation Matrix
|
||||
|
||||
This matrix covers end-to-end video production. Each stage lists a **primary goal**, **CLI tools** from the matrix, and **alternative approaches** (Python libraries, cloud APIs, native commands). Pick the approach that fits your current environment and constraints.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Install the whole matrix
|
||||
cli-hub matrix install video-creation
|
||||
|
||||
# Inspect what it includes
|
||||
cli-hub matrix info video-creation
|
||||
```
|
||||
|
||||
## Stages
|
||||
|
||||
### 1. AI Video Generation
|
||||
**Goal:** Generate video clips from text prompts or reference images
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `generate-veo-video` (Google Veo 3.1), `jimeng` (ByteDance Dreamina) |
|
||||
| Python libs | `replicate` (Runway, Kling), `diffusers` |
|
||||
| Cloud APIs | Runway API, Kling API, Pika API |
|
||||
|
||||
### 2. Screen/Cam Capture
|
||||
**Goal:** Record screen activity, webcam, or application windows to video files
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `cli-anything-openscreen` |
|
||||
| Python libs | `pyautogui` + `cv2`, `mss` |
|
||||
| Native cmds | `ffmpeg -f x11grab` (Linux), `screencapture` (macOS) |
|
||||
|
||||
### 3. Audio Capture/Edit
|
||||
**Goal:** Record, clean, trim, normalize, and process audio tracks
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `cli-anything-audacity` |
|
||||
| Python libs | `pydub`, `soundfile`, `librosa`, `noisereduce` |
|
||||
| Native cmds | `sox`, `ffmpeg` |
|
||||
|
||||
### 4. Voice/TTS
|
||||
**Goal:** Generate speech audio from text for narration or voiceover
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `minimax-cli`, `elevenlabs` |
|
||||
| Python libs | `TTS` (coqui-ai), `pyttsx3`, `edge-tts` |
|
||||
| Cloud APIs | Google Cloud TTS, Amazon Polly, Azure Speech |
|
||||
|
||||
### 5. Music/BGM Generation
|
||||
**Goal:** Generate background music or sound effects from prompts
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `minimax-cli`, `suno` |
|
||||
| Python libs | `audiocraft` (Meta MusicGen), `stable-audio-tools` |
|
||||
| Cloud APIs | Suno API, Udio API |
|
||||
|
||||
### 6. NLE Editing
|
||||
**Goal:** Assemble video/audio clips on a timeline, add transitions, and export the final cut
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `cli-anything-kdenlive`, `cli-anything-shotcut` |
|
||||
| Python libs | `moviepy`, `ffmpeg-python` |
|
||||
| Native cmds | `ffmpeg concat/filter_complex` |
|
||||
|
||||
### 7. Captions/Subtitles
|
||||
**Goal:** Transcribe speech, generate subtitles (SRT/ASS), optionally translate, and burn into video
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `cli-anything-videocaptioner` |
|
||||
| Python libs | `whisper` (openai-whisper), `pysrt`, `stable-ts` |
|
||||
| Cloud APIs | Google Speech-to-Text, AssemblyAI, Deepgram |
|
||||
| Native cmds | `ffmpeg -vf subtitles=subs.srt` |
|
||||
|
||||
### 8. Thumbnail
|
||||
**Goal:** Create a thumbnail image or social card for the video
|
||||
|
||||
| Approach | Options |
|
||||
|----------|---------|
|
||||
| CLI tools | `cli-anything-gimp` |
|
||||
| Python libs | `Pillow`, `cairosvg`, `html2image` |
|
||||
| Native cmds | `ffmpeg -ss 00:01:00 -frames:v 1 thumb.png`, ImageMagick `convert` |
|
||||
|
||||
## Discovering Additional Tools
|
||||
|
||||
Before starting a stage, search for skills and tools that may already be available:
|
||||
|
||||
```bash
|
||||
# Search for Claude Code skills by keyword
|
||||
npx skills search "subtitle"
|
||||
npx skills search "video editing"
|
||||
npx skills search "text-to-speech"
|
||||
|
||||
# Check what CLI-Hub has available
|
||||
cli-hub search caption
|
||||
cli-hub search audio
|
||||
cli-hub search video
|
||||
|
||||
# Check for Python packages already installed
|
||||
pip list 2>/dev/null | grep -iE "whisper|moviepy|pydub|Pillow"
|
||||
|
||||
# Check for native tools on PATH
|
||||
which ffmpeg sox convert magick 2>/dev/null
|
||||
```
|
||||
|
||||
When a stage's CLI tool is not installed and installing it is impractical, fall back to:
|
||||
1. A Python library you can `pip install` in seconds
|
||||
2. A native command already on the system (`ffmpeg`, `sox`, `ImageMagick`)
|
||||
3. A cloud API if credentials are available in the environment
|
||||
|
||||
## Operating Pattern
|
||||
|
||||
1. The controller agent drafts the script, shot list, and prompt variants.
|
||||
2. For each stage, check what tools are available (installed CLIs, Python libs on PATH, API keys in env).
|
||||
3. Pick the simplest adequate tool for each stage -- a full CLI harness is not always needed.
|
||||
4. Generate/capture media, assemble the edit, add captions, and create the thumbnail.
|
||||
5. Keep all intermediate assets under a single workspace directory so sources stay linkable across tools.
|
||||
|
||||
## Example Flow
|
||||
|
||||
```bash
|
||||
# 1) Discover what is available
|
||||
cli-hub matrix info video-creation
|
||||
npx skills search "video"
|
||||
which ffmpeg && echo "ffmpeg available"
|
||||
|
||||
# 2) Generate hero clips
|
||||
cli-hub launch generate-veo-video --help
|
||||
# or: python -c "import replicate; ..."
|
||||
|
||||
# 3) Generate narration
|
||||
cli-hub launch elevenlabs --help
|
||||
# or: pip install edge-tts && edge-tts --text "Hello" --write-media narration.mp3
|
||||
|
||||
# 4) Assemble edit
|
||||
cli-anything-kdenlive --json project new -o edit.json
|
||||
cli-anything-kdenlive --json --project edit.json timeline add-track --type video
|
||||
# or: python -c "from moviepy.editor import *; ..."
|
||||
|
||||
# 5) Add subtitles
|
||||
cli-anything-videocaptioner process cut.mp4 --asr bijian --subtitle-mode hard -o captioned.mp4
|
||||
# or: pip install openai-whisper && python -c "import whisper; model = whisper.load_model('base'); ..."
|
||||
|
||||
# 6) Create thumbnail
|
||||
cli-anything-gimp --json project new -o thumb.json
|
||||
# or: python -c "from PIL import Image, ImageDraw, ImageFont; ..."
|
||||
```
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Check before installing**: If `whisper` is already pip-installed, use it directly instead of installing `cli-anything-videocaptioner` for a simple transcription task.
|
||||
- **Match complexity to task**: A one-line `ffmpeg` command may beat a full CLI harness for simple operations like extracting a thumbnail or concatenating two clips.
|
||||
- **Search for skills first**: Before each stage, run `npx skills search "<keyword>"` with the stage's topic to discover relevant skills you may not know about.
|
||||
- **Prefer `--json` for harness CLIs**: When chaining harness CLI outputs to the next tool, use `--json` for structured data.
|
||||
- **Use env vars for API routing**: Check for `GOOGLE_CLOUD_PROJECT`, `ELEVENLABS_API_KEY`, `MINIMAX_API_KEY`, etc. before choosing between CLI and direct API approaches.
|
||||
- The matrix assumes a controller agent (Claude Code, Codex, OpenClaw, etc.) is doing the planning and orchestration.
|
||||
+152
-4
@@ -3,12 +3,15 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import json as json_mod
|
||||
|
||||
import click
|
||||
|
||||
from cli_hub import __version__
|
||||
from cli_hub.registry import fetch_all_clis, get_cli, search_clis, list_categories
|
||||
from cli_hub.installer import install_cli, uninstall_cli, get_installed, update_cli
|
||||
from cli_hub.matrix import fetch_all_matrices, get_matrix, search_matrices
|
||||
from cli_hub.matrix_skill import get_rendered_matrix_skill_path
|
||||
from cli_hub.installer import install_cli, uninstall_cli, get_installed, update_cli, install_matrix
|
||||
from cli_hub.analytics import (
|
||||
detect_invocation_context,
|
||||
track_first_run,
|
||||
@@ -37,7 +40,7 @@ def _invocation_command(ctx, version):
|
||||
@click.option("--version", is_flag=True, help="Show version.")
|
||||
@click.pass_context
|
||||
def main(ctx, version):
|
||||
"""cli-hub — Download and manage CLI-Anything harnesses and public CLIs."""
|
||||
"""cli-hub — Download and manage CLI-Anything CLIs and curated matrices."""
|
||||
track_first_run()
|
||||
track_visit(command=_invocation_command(ctx, version), detection=detect_invocation_context())
|
||||
if version:
|
||||
@@ -128,7 +131,6 @@ def list_clis(category, source, as_json):
|
||||
installed = get_installed()
|
||||
|
||||
if as_json:
|
||||
import json as json_mod
|
||||
click.echo(json_mod.dumps(clis, indent=2))
|
||||
return
|
||||
|
||||
@@ -168,7 +170,6 @@ def search(query, as_json):
|
||||
results = search_clis(query)
|
||||
|
||||
if as_json:
|
||||
import json as json_mod
|
||||
click.echo(json_mod.dumps(results, indent=2))
|
||||
return
|
||||
|
||||
@@ -216,6 +217,8 @@ def info(name):
|
||||
click.echo(f" Version: {cli['version']}")
|
||||
click.echo(f" Requires: {cli.get('requires') or 'nothing'}")
|
||||
click.echo(f" Entry point: {cli['entry_point']}")
|
||||
if cli.get("skill_md"):
|
||||
click.echo(f" Skill: {cli['skill_md']}")
|
||||
click.echo(f" Homepage: {cli.get('homepage', 'N/A')}")
|
||||
contributors = cli.get("contributors", [])
|
||||
if contributors:
|
||||
@@ -250,5 +253,150 @@ def launch(name, args):
|
||||
os.execvp(entry, [entry] + list(args))
|
||||
|
||||
|
||||
@main.group(name="matrix", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def matrix(ctx):
|
||||
"""Browse and install curated multi-CLI workflow matrices."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
@matrix.command("list")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||
def list_matrices(as_json):
|
||||
"""List all available matrices."""
|
||||
try:
|
||||
matrices = fetch_all_matrices()
|
||||
except Exception as e:
|
||||
click.secho(f"Failed to fetch matrix registry: {e}", fg="red", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
installed = get_installed()
|
||||
|
||||
if as_json:
|
||||
click.echo(json_mod.dumps(matrices, indent=2))
|
||||
return
|
||||
|
||||
if not matrices:
|
||||
click.echo("No matrices found.")
|
||||
return
|
||||
|
||||
click.secho("\n MATRICES", fg="blue", bold=True)
|
||||
for matrix_item in sorted(matrices, key=lambda s: s["name"]):
|
||||
installed_count = sum(1 for cli_name in matrix_item.get("clis", []) if cli_name in installed)
|
||||
total = len(matrix_item.get("clis", []))
|
||||
marker = click.style(" ●", fg="green") if total and installed_count == total else " "
|
||||
name = click.style(f"{matrix_item['name']:20s}", bold=True)
|
||||
matrix_label = click.style(f"[{matrix_item.get('matrix_id', 'matrix')}]", fg="cyan")
|
||||
click.echo(f" {marker} {name} {matrix_label} {matrix_item['description'][:65]}")
|
||||
click.echo(f" Includes: {installed_count}/{total} CLIs installed")
|
||||
|
||||
click.echo(f"\n {len(matrices)} matrices available")
|
||||
|
||||
|
||||
@matrix.command("search")
|
||||
@click.argument("query")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||
def matrix_search(query, as_json):
|
||||
"""Search matrices by name, description, or category."""
|
||||
results = search_matrices(query)
|
||||
|
||||
if as_json:
|
||||
click.echo(json_mod.dumps(results, indent=2))
|
||||
return
|
||||
|
||||
if not results:
|
||||
click.echo(f"No matrices matching '{query}'.")
|
||||
return
|
||||
|
||||
installed = get_installed()
|
||||
for matrix_item in results:
|
||||
cli_names = matrix_item.get("clis", [])
|
||||
installed_count = sum(1 for c in cli_names if c in installed)
|
||||
total = len(cli_names)
|
||||
name_str = click.style(matrix_item["name"], bold=True)
|
||||
matrix_label = click.style(f"[{matrix_item.get('matrix_id', 'matrix')}]", fg="cyan")
|
||||
click.echo(f" {name_str} {matrix_label} — {matrix_item['description'][:65]}")
|
||||
click.echo(f" CLIs: {installed_count}/{total} installed")
|
||||
click.echo(f" Install: cli-hub matrix install {matrix_item['name']}")
|
||||
|
||||
|
||||
@matrix.command("info")
|
||||
@click.argument("name")
|
||||
def matrix_info(name):
|
||||
"""Show details for a specific matrix."""
|
||||
matrix_item = get_matrix(name)
|
||||
if not matrix_item:
|
||||
click.secho(f"Matrix '{name}' not found.", fg="red", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
installed = get_installed()
|
||||
cli_names = matrix_item.get("clis", [])
|
||||
installed_count = sum(1 for cli_name in cli_names if cli_name in installed)
|
||||
|
||||
click.secho(f"\n {matrix_item['display_name']}", bold=True)
|
||||
click.echo(f" {matrix_item['description']}")
|
||||
click.echo(f" Matrix: {matrix_item.get('matrix', 'N/A')} {matrix_item.get('matrix_id', '')}".rstrip())
|
||||
click.echo(f" Category: {matrix_item.get('category', 'N/A')}")
|
||||
click.echo(f" CLIs: {len(cli_names)}")
|
||||
click.echo(f" Installed: {installed_count}/{len(cli_names)}")
|
||||
if matrix_item.get("skill_md"):
|
||||
click.echo(f" Skill: {matrix_item['skill_md']}")
|
||||
rendered_skill_path = get_rendered_matrix_skill_path(matrix_item["name"])
|
||||
if rendered_skill_path.exists():
|
||||
click.echo(f" Local skill: {rendered_skill_path}")
|
||||
if matrix_item.get("homepage"):
|
||||
click.echo(f" Homepage: {matrix_item['homepage']}")
|
||||
|
||||
click.echo("\n Members:")
|
||||
for cli_name in cli_names:
|
||||
status = click.style("installed", fg="green") if cli_name in installed else "not installed"
|
||||
click.echo(f" - {cli_name} ({status})")
|
||||
|
||||
stages = matrix_item.get("stages", [])
|
||||
if stages:
|
||||
click.echo("\n Stage Coverage:")
|
||||
for stage in stages:
|
||||
members = ", ".join(stage.get("clis", []))
|
||||
goal = stage.get("goal", "")
|
||||
goal_suffix = f" -- {goal}" if goal else ""
|
||||
click.echo(f" - {stage['name']}: {members}{goal_suffix}")
|
||||
|
||||
click.echo(f"\n Install: cli-hub matrix install {matrix_item['name']}")
|
||||
click.echo()
|
||||
|
||||
|
||||
@matrix.command("install")
|
||||
@click.argument("name")
|
||||
def matrix_install(name):
|
||||
"""Install every CLI in a matrix."""
|
||||
click.echo(f"Installing matrix {name}...")
|
||||
success, payload = install_matrix(name)
|
||||
if payload.get("error"):
|
||||
click.secho(f"✗ {payload['error']}", fg="red", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
matrix_item = payload["matrix"]
|
||||
for result in payload["results"]:
|
||||
status = result["status"]
|
||||
prefix = "✓" if status in {"installed", "skipped"} else "✗"
|
||||
color = "green" if status in {"installed", "skipped"} else "red"
|
||||
click.secho(f" {prefix} {result['name']}: {result['message']}", fg=color, err=status == "failed")
|
||||
|
||||
summary = payload["summary"]
|
||||
click.echo(
|
||||
f"\n Summary: {summary['installed']} installed, "
|
||||
f"{summary['skipped']} skipped, {summary['failed']} failed"
|
||||
)
|
||||
if matrix_item.get("skill_md"):
|
||||
click.echo(f" Matrix skill: {matrix_item['skill_md']}")
|
||||
if payload.get("rendered_skill_path"):
|
||||
click.echo(f" Local matrix skill: {payload['rendered_skill_path']}")
|
||||
click.echo(f" Inspect: cli-hub matrix info {matrix_item['name']}")
|
||||
|
||||
if not success:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Install, uninstall, and manage CLIs — dispatches to pip or npm based on source."""
|
||||
"""Install, uninstall, and manage CLIs and matrices."""
|
||||
|
||||
import json
|
||||
import shlex
|
||||
@@ -8,6 +8,8 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cli_hub.registry import get_cli
|
||||
from cli_hub.matrix import get_matrix
|
||||
from cli_hub.matrix_skill import render_matrix_skill_file
|
||||
|
||||
INSTALLED_FILE = Path.home() / ".cli-hub" / "installed.json"
|
||||
|
||||
@@ -371,3 +373,66 @@ def update_cli(name):
|
||||
def get_installed():
|
||||
"""Return dict of installed CLIs."""
|
||||
return _load_installed()
|
||||
|
||||
|
||||
def install_matrix(name):
|
||||
"""Install every CLI in a named matrix.
|
||||
|
||||
Returns ``(success, payload)`` where payload contains the matrix metadata,
|
||||
per-CLI results, and an aggregate summary. Success is false when any member
|
||||
install fails or the matrix cannot be found.
|
||||
"""
|
||||
matrix_item = get_matrix(name)
|
||||
if matrix_item is None:
|
||||
return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."}
|
||||
|
||||
installed = set(get_installed())
|
||||
results = []
|
||||
|
||||
for cli_name in matrix_item.get("clis", []):
|
||||
cli = get_cli(cli_name)
|
||||
display_name = cli["display_name"] if cli else cli_name
|
||||
|
||||
if cli_name in installed:
|
||||
results.append({
|
||||
"name": cli_name,
|
||||
"display_name": display_name,
|
||||
"status": "skipped",
|
||||
"message": "Already installed",
|
||||
})
|
||||
continue
|
||||
|
||||
if cli is None:
|
||||
results.append({
|
||||
"name": cli_name,
|
||||
"display_name": display_name,
|
||||
"status": "failed",
|
||||
"message": "CLI not found in registry",
|
||||
})
|
||||
continue
|
||||
|
||||
success, msg = install_cli(cli_name)
|
||||
results.append({
|
||||
"name": cli_name,
|
||||
"display_name": display_name,
|
||||
"status": "installed" if success else "failed",
|
||||
"message": msg,
|
||||
})
|
||||
if success:
|
||||
installed.add(cli_name)
|
||||
|
||||
summary = {
|
||||
"total": len(results),
|
||||
"installed": sum(1 for result in results if result["status"] == "installed"),
|
||||
"skipped": sum(1 for result in results if result["status"] == "skipped"),
|
||||
"failed": sum(1 for result in results if result["status"] == "failed"),
|
||||
}
|
||||
installed_state = get_installed()
|
||||
rendered_skill_path = render_matrix_skill_file(matrix_item, installed=installed_state)
|
||||
payload = {
|
||||
"matrix": matrix_item,
|
||||
"results": results,
|
||||
"summary": summary,
|
||||
"rendered_skill_path": str(rendered_skill_path),
|
||||
}
|
||||
return summary["failed"] == 0, payload
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Fetch, cache, and query curated CLI workflow matrices."""
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
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 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
|
||||
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 search_matrices(query, force_refresh=False):
|
||||
"""Search matrices by name, display name, description, or category."""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
for matrix_item in fetch_all_matrices(force_refresh):
|
||||
haystack_values = [
|
||||
matrix_item["name"],
|
||||
matrix_item.get("display_name", ""),
|
||||
matrix_item.get("description", ""),
|
||||
matrix_item.get("category", ""),
|
||||
matrix_item.get("matrix_id", ""),
|
||||
]
|
||||
if any(query_lower in value.lower() for value in haystack_values):
|
||||
results.append(matrix_item)
|
||||
return results
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Render local matrix skill files with resolved CLI skill paths."""
|
||||
|
||||
import subprocess
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
|
||||
from cli_hub.registry import get_cli
|
||||
|
||||
MATRIX_SKILL_DIR = Path.home() / ".cli-hub" / "matrix"
|
||||
|
||||
|
||||
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."""
|
||||
return MATRIX_SKILL_DIR / f"{name}.SKILL.md"
|
||||
|
||||
|
||||
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."""
|
||||
output_path = get_rendered_matrix_skill_path(matrix_item["name"])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base_content = _load_matrix_skill_template(matrix_item).rstrip()
|
||||
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}\n\n<!-- MATRIX_SKILL_PATHS:END -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
def _load_matrix_skill_template(matrix_item):
|
||||
"""Load the tracked matrix skill template when available."""
|
||||
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.read_text(encoding="utf-8")
|
||||
|
||||
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']}`."
|
||||
)
|
||||
|
||||
|
||||
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} |"
|
||||
)
|
||||
|
||||
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 _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'])}")
|
||||
|
||||
hints = stage.get("skill_search_hints", [])
|
||||
if hints:
|
||||
cmds = ", ".join(f'`npx skills search "{h}"`' for h in hints[:2])
|
||||
lines.append(f"- Search for skills: {cmds}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_discovery_section(matrix_item):
|
||||
"""Render aggregated skill discovery commands from all stages."""
|
||||
all_hints = []
|
||||
for stage in matrix_item.get("stages", []):
|
||||
for hint in stage.get("skill_search_hints", []):
|
||||
if hint not in all_hints:
|
||||
all_hints.append(hint)
|
||||
|
||||
if not all_hints:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"## Skill Discovery Commands",
|
||||
"",
|
||||
"Search for relevant skills and tools across all stages:",
|
||||
"",
|
||||
"```bash",
|
||||
]
|
||||
|
||||
for hint in all_hints:
|
||||
lines.append(f'npx skills search "{hint}"')
|
||||
|
||||
lines.extend([
|
||||
"```",
|
||||
"",
|
||||
"Also check CLI-Hub:",
|
||||
"",
|
||||
"```bash",
|
||||
])
|
||||
|
||||
seen = set()
|
||||
for hint in all_hints:
|
||||
keyword = hint.split()[0].lower()
|
||||
if keyword not in seen:
|
||||
seen.add(keyword)
|
||||
lines.append(f"cli-hub search {keyword}")
|
||||
|
||||
lines.append("```")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -12,8 +12,11 @@ import requests
|
||||
|
||||
from cli_hub import __version__
|
||||
from cli_hub.registry import fetch_registry, fetch_all_clis, get_cli, search_clis, list_categories
|
||||
from cli_hub.matrix import fetch_matrix_registry, fetch_all_matrices, get_matrix, search_matrices
|
||||
from cli_hub.matrix_skill import resolve_local_skill_path, render_matrix_skill_file, _render_stage_tooling, _render_discovery_section
|
||||
from cli_hub.installer import (
|
||||
install_cli,
|
||||
install_matrix,
|
||||
uninstall_cli,
|
||||
get_installed,
|
||||
_load_installed,
|
||||
@@ -40,7 +43,7 @@ SAMPLE_REGISTRY = {
|
||||
"homepage": "https://gimp.org",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=gimp/agent-harness",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"skill_md": "skills/cli-anything-gimp/SKILL.md",
|
||||
"skill_md": "gimp/agent-harness/cli_anything/gimp/skills/SKILL.md",
|
||||
"category": "image",
|
||||
"contributor": "test-user",
|
||||
"contributor_url": "https://github.com/test-user",
|
||||
@@ -76,6 +79,39 @@ SAMPLE_REGISTRY = {
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_MATRIX_REGISTRY = {
|
||||
"meta": {"repo": "https://github.com/HKUDS/CLI-Anything", "description": "test matrices"},
|
||||
"matrices": [
|
||||
{
|
||||
"name": "video-creation",
|
||||
"display_name": "Video Creation & Editing",
|
||||
"description": "Curated video workflow matrix",
|
||||
"category": "video",
|
||||
"matrix": "cli-matrix",
|
||||
"matrix_id": "V1",
|
||||
"skill_md": "cli-hub-matrix/video-creation/SKILL.md",
|
||||
"clis": ["gimp", "blender", "audacity"],
|
||||
"stages": [
|
||||
{
|
||||
"name": "Thumbnail",
|
||||
"clis": ["gimp"],
|
||||
"goal": "Create a thumbnail image",
|
||||
"alternatives": {"python": ["Pillow"], "native": ["ImageMagick convert"]},
|
||||
"skill_search_hints": ["thumbnail", "image editing"],
|
||||
},
|
||||
{"name": "3D", "clis": ["blender"]},
|
||||
{
|
||||
"name": "Audio",
|
||||
"clis": ["audacity"],
|
||||
"goal": "Edit and process audio",
|
||||
"alternatives": {"python": ["pydub"], "native": ["sox"]},
|
||||
"skill_search_hints": ["audio editing"],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Registry tests ───────────────────────────────────────────────────
|
||||
|
||||
@@ -152,6 +188,138 @@ class TestRegistry:
|
||||
cats = list_categories()
|
||||
assert cats == ["3d", "audio", "image"]
|
||||
|
||||
|
||||
class TestMatrixRegistry:
|
||||
"""Tests for matrix.py — fetch, cache, search, and lookup."""
|
||||
|
||||
@patch("cli_hub.matrix.requests.get")
|
||||
@patch("cli_hub.matrix.MATRIX_CACHE_FILE", Path(tempfile.mktemp()))
|
||||
def test_fetch_matrix_registry_from_remote(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = SAMPLE_MATRIX_REGISTRY
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = fetch_matrix_registry(force_refresh=True)
|
||||
assert result["matrices"][0]["name"] == "video-creation"
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"])
|
||||
def test_get_matrix_found(self, mock_fetch):
|
||||
matrix_item = get_matrix("video-creation")
|
||||
assert matrix_item is not None
|
||||
assert matrix_item["display_name"] == "Video Creation & Editing"
|
||||
|
||||
@patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"])
|
||||
def test_search_matrices_matches_description(self, mock_fetch):
|
||||
results = search_matrices("video")
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "video-creation"
|
||||
|
||||
|
||||
class TestMatrixSkill:
|
||||
"""Tests for matrix_skill.py — local skill resolution and rendering."""
|
||||
|
||||
@patch("cli_hub.matrix_skill.metadata.distribution")
|
||||
def test_resolve_local_skill_path_from_distribution(self, mock_distribution, tmp_path):
|
||||
class FakeDist:
|
||||
files = [Path("cli_anything/audacity/skills/SKILL.md")]
|
||||
|
||||
def locate_file(self, file):
|
||||
return tmp_path / file
|
||||
|
||||
mock_distribution.return_value = FakeDist()
|
||||
cli = {"name": "audacity", "_source": "harness"}
|
||||
resolved = resolve_local_skill_path(cli)
|
||||
assert resolved == str((tmp_path / "cli_anything/audacity/skills/SKILL.md").resolve())
|
||||
|
||||
@patch("cli_hub.matrix_skill.MATRIX_SKILL_DIR", Path(tempfile.mkdtemp()))
|
||||
@patch("cli_hub.matrix_skill.resolve_local_skill_path")
|
||||
@patch("cli_hub.matrix_skill.get_cli")
|
||||
def test_render_matrix_skill_file_injects_paths(self, mock_get_cli, mock_resolve, tmp_dir=None):
|
||||
mock_get_cli.side_effect = lambda name: next((c for c in SAMPLE_REGISTRY["clis"] if c["name"] == name), None)
|
||||
mock_resolve.side_effect = lambda cli: f"/tmp/{cli['name']}/skills/SKILL.md" if cli["name"] != "blender" else None
|
||||
|
||||
rendered = render_matrix_skill_file(SAMPLE_MATRIX_REGISTRY["matrices"][0], installed={"gimp": {}, "audacity": {}})
|
||||
content = Path(rendered).read_text()
|
||||
assert "## Installed CLI Skills" in content
|
||||
assert "/tmp/gimp/skills/SKILL.md" in content
|
||||
assert "gimp/agent-harness/cli_anything/gimp/skills/SKILL.md" in content
|
||||
assert "not installed" in content
|
||||
|
||||
|
||||
class TestMultiApproachRendering:
|
||||
"""Tests for multi-approach stage rendering in matrix_skill.py."""
|
||||
|
||||
def test_render_stage_tooling_includes_goals(self):
|
||||
matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0]
|
||||
result = _render_stage_tooling(matrix_item, installed={"gimp": {}})
|
||||
assert "## Stage Tooling Overview" in result
|
||||
assert "Create a thumbnail image" in result
|
||||
assert "Edit and process audio" in result
|
||||
|
||||
def test_render_stage_tooling_includes_alternatives(self):
|
||||
matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0]
|
||||
result = _render_stage_tooling(matrix_item, installed={})
|
||||
assert "Pillow" in result
|
||||
assert "pydub" in result
|
||||
assert "sox" in result
|
||||
assert "ImageMagick convert" in result
|
||||
|
||||
def test_render_stage_tooling_shows_install_status(self):
|
||||
matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0]
|
||||
result = _render_stage_tooling(matrix_item, installed={"gimp": {}})
|
||||
assert "`gimp` (installed)" in result
|
||||
assert "`audacity` (not installed)" in result
|
||||
|
||||
def test_render_stage_tooling_includes_skill_search_hints(self):
|
||||
matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0]
|
||||
result = _render_stage_tooling(matrix_item, installed={})
|
||||
assert 'npx skills search "thumbnail"' in result
|
||||
assert 'npx skills search "audio editing"' in result
|
||||
|
||||
def test_render_stage_tooling_backward_compat_no_goal(self):
|
||||
"""Stages without 'goal' field are skipped gracefully."""
|
||||
matrix_no_goals = {
|
||||
"name": "test",
|
||||
"stages": [
|
||||
{"name": "Stage1", "clis": ["foo"]},
|
||||
],
|
||||
}
|
||||
result = _render_stage_tooling(matrix_no_goals, installed={})
|
||||
assert result == ""
|
||||
|
||||
def test_render_discovery_section(self):
|
||||
matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0]
|
||||
result = _render_discovery_section(matrix_item)
|
||||
assert "## Skill Discovery Commands" in result
|
||||
assert 'npx skills search "thumbnail"' in result
|
||||
assert 'npx skills search "audio editing"' in result
|
||||
assert "cli-hub search thumbnail" in result
|
||||
assert "cli-hub search audio" in result
|
||||
|
||||
def test_render_discovery_section_empty_when_no_hints(self):
|
||||
matrix_no_hints = {
|
||||
"name": "test",
|
||||
"stages": [{"name": "S1", "clis": ["foo"]}],
|
||||
}
|
||||
result = _render_discovery_section(matrix_no_hints)
|
||||
assert result == ""
|
||||
|
||||
@patch("cli_hub.matrix_skill.MATRIX_SKILL_DIR", Path(tempfile.mkdtemp()))
|
||||
@patch("cli_hub.matrix_skill.resolve_local_skill_path")
|
||||
@patch("cli_hub.matrix_skill.get_cli")
|
||||
def test_render_matrix_skill_file_includes_stage_tooling(self, mock_get_cli, mock_resolve):
|
||||
mock_get_cli.side_effect = lambda name: next((c for c in SAMPLE_REGISTRY["clis"] if c["name"] == name), None)
|
||||
mock_resolve.return_value = None
|
||||
|
||||
rendered = render_matrix_skill_file(SAMPLE_MATRIX_REGISTRY["matrices"][0], installed={"gimp": {}})
|
||||
content = Path(rendered).read_text()
|
||||
assert "## Stage Tooling Overview" in content
|
||||
assert "## Skill Discovery Commands" in content
|
||||
assert "Create a thumbnail image" in content
|
||||
|
||||
|
||||
# ─── Installer tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -484,6 +652,27 @@ class TestScriptStrategy:
|
||||
assert data["jimeng"]["strategy"] == "command"
|
||||
assert data["jimeng"]["package_manager"] == "script"
|
||||
|
||||
@patch("cli_hub.installer.get_matrix", return_value=SAMPLE_MATRIX_REGISTRY["matrices"][0])
|
||||
@patch("cli_hub.installer.get_cli", side_effect=lambda name: next((c for c in SAMPLE_REGISTRY["clis"] if c["name"] == name), None))
|
||||
@patch("cli_hub.installer.install_cli", side_effect=[(True, "Installed Blender"), (False, "Install failed")])
|
||||
@patch("cli_hub.installer.get_installed", return_value={"gimp": {"version": "1.0.0"}})
|
||||
@patch("cli_hub.installer.render_matrix_skill_file", return_value=Path("/tmp/video-creation.SKILL.md"))
|
||||
def test_install_matrix_reports_installed_skipped_and_failed(self, mock_render, mock_installed, mock_install_cli, mock_get_cli, mock_get_matrix):
|
||||
success, payload = install_matrix("video-creation")
|
||||
assert not success
|
||||
assert payload["summary"] == {"total": 3, "installed": 1, "skipped": 1, "failed": 1}
|
||||
assert payload["results"][0]["status"] == "skipped"
|
||||
assert payload["results"][1]["status"] == "installed"
|
||||
assert payload["results"][2]["status"] == "failed"
|
||||
assert payload["rendered_skill_path"] == "/tmp/video-creation.SKILL.md"
|
||||
|
||||
@patch("cli_hub.installer.get_matrix", return_value=None)
|
||||
def test_install_matrix_not_found(self, mock_get_matrix):
|
||||
success, payload = install_matrix("missing-matrix")
|
||||
assert not success
|
||||
assert "not found" in payload["error"]
|
||||
|
||||
|
||||
# ─── Analytics tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -760,6 +949,87 @@ class TestCLI:
|
||||
assert "cli-hub" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@patch("cli_hub.cli.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"])
|
||||
@patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}})
|
||||
def test_matrix_list_command(self, mock_installed, mock_fetch_matrices, mock_detect, mock_visit, mock_first_run):
|
||||
mock_detect.return_value = self.human_detection
|
||||
result = self.runner.invoke(main, ["matrix", "list"])
|
||||
assert "video-creation" in result.output
|
||||
assert "1/3" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@patch("cli_hub.cli.search_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"])
|
||||
@patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}})
|
||||
def test_matrix_search_command(self, mock_installed, mock_search, mock_detect, mock_visit, mock_first_run):
|
||||
mock_detect.return_value = self.human_detection
|
||||
result = self.runner.invoke(main, ["matrix", "search", "video"])
|
||||
assert "video-creation" in result.output
|
||||
assert "1/3" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@patch("cli_hub.cli.search_matrices", return_value=[])
|
||||
def test_matrix_search_no_results(self, mock_search, mock_detect, mock_visit, mock_first_run):
|
||||
mock_detect.return_value = self.human_detection
|
||||
result = self.runner.invoke(main, ["matrix", "search", "nonexistent"])
|
||||
assert "No matrices matching" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@patch("cli_hub.cli.get_matrix", return_value=SAMPLE_MATRIX_REGISTRY["matrices"][0])
|
||||
@patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}})
|
||||
@patch("cli_hub.cli.get_rendered_matrix_skill_path", return_value=Path("/tmp/video-creation.SKILL.md"))
|
||||
@patch("pathlib.Path.exists", return_value=True)
|
||||
def test_matrix_info_command(self, mock_exists, mock_rendered, mock_installed, mock_get_matrix, mock_detect, mock_visit, mock_first_run):
|
||||
mock_detect.return_value = self.human_detection
|
||||
result = self.runner.invoke(main, ["matrix", "info", "video-creation"])
|
||||
assert "Video Creation & Editing" in result.output
|
||||
assert "cli-hub matrix install video-creation" in result.output
|
||||
assert "cli-hub-matrix/video-creation/SKILL.md" in result.output
|
||||
assert "Local skill: /tmp/video-creation.SKILL.md" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@patch("cli_hub.cli.install_matrix", return_value=(False, {
|
||||
"matrix": SAMPLE_MATRIX_REGISTRY["matrices"][0],
|
||||
"results": [
|
||||
{"name": "gimp", "status": "skipped", "message": "Already installed"},
|
||||
{"name": "blender", "status": "installed", "message": "Installed Blender"},
|
||||
{"name": "audacity", "status": "failed", "message": "Install failed"},
|
||||
],
|
||||
"summary": {"installed": 1, "skipped": 1, "failed": 1},
|
||||
"rendered_skill_path": "/tmp/video-creation.SKILL.md",
|
||||
}))
|
||||
def test_matrix_install_command_partial_failure(self, mock_install_matrix, mock_detect, mock_visit, mock_first_run):
|
||||
mock_detect.return_value = self.human_detection
|
||||
result = self.runner.invoke(main, ["matrix", "install", "video-creation"])
|
||||
assert result.exit_code == 1
|
||||
assert "Summary: 1 installed, 1 skipped, 1 failed" in result.output
|
||||
assert "Matrix skill: cli-hub-matrix/video-creation/SKILL.md" in result.output
|
||||
assert "Local matrix skill: /tmp/video-creation.SKILL.md" in result.output
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@patch("cli_hub.cli.install_matrix", return_value=(False, {"error": "Matrix 'missing' not found."}))
|
||||
def test_matrix_install_command_not_found(self, mock_install_matrix, mock_detect, mock_visit, mock_first_run):
|
||||
mock_detect.return_value = self.human_detection
|
||||
result = self.runner.invoke(main, ["matrix", "install", "missing"])
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.output
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
@patch("cli_hub.cli.track_visit")
|
||||
@patch("cli_hub.cli.detect_invocation_context")
|
||||
@@ -807,6 +1077,7 @@ class TestCLI:
|
||||
assert "GIMP" in result.output
|
||||
assert "image" in result.output
|
||||
assert "Install: cli-hub install gimp" in result.output
|
||||
assert "Skill:" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_hub.cli.track_first_run")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"meta": {
|
||||
"repo": "https://github.com/HKUDS/CLI-Anything",
|
||||
"description": "Curated CLI Matrix registry for CLI-Hub multi-CLI workflows",
|
||||
"updated": "2026-04-17"
|
||||
},
|
||||
"matrices": [
|
||||
{
|
||||
"name": "video-creation",
|
||||
"display_name": "Video Creation & Editing",
|
||||
"version": "2",
|
||||
"description": "Curated end-to-end video production matrix spanning generation, capture, voice/music, editing, captioning, and thumbnailing for an agent-driven workflow.",
|
||||
"category": "video",
|
||||
"matrix": "cli-matrix",
|
||||
"matrix_id": "V1",
|
||||
"homepage": "https://github.com/HKUDS/CLI-Anything/blob/main/docs/cli-matrix.md",
|
||||
"skill_md": "cli-hub-matrix/video-creation/SKILL.md",
|
||||
"clis": [
|
||||
"generate-veo-video",
|
||||
"jimeng",
|
||||
"openscreen",
|
||||
"audacity",
|
||||
"minimax-cli",
|
||||
"elevenlabs",
|
||||
"suno",
|
||||
"kdenlive",
|
||||
"shotcut",
|
||||
"videocaptioner",
|
||||
"gimp"
|
||||
],
|
||||
"stages": [
|
||||
{
|
||||
"name": "AI video gen",
|
||||
"goal": "Generate video clips from text prompts or reference images",
|
||||
"clis": [
|
||||
"generate-veo-video",
|
||||
"jimeng"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["replicate (Runway, Kling)", "diffusers"],
|
||||
"api": ["Runway API", "Kling API", "Pika API"]
|
||||
},
|
||||
"skill_search_hints": ["video generation", "text-to-video", "AI video"]
|
||||
},
|
||||
{
|
||||
"name": "Screen/cam capture",
|
||||
"goal": "Record screen activity, webcam, or application windows to video files",
|
||||
"clis": [
|
||||
"openscreen"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["pyautogui + cv2", "mss"],
|
||||
"native": ["ffmpeg -f x11grab (Linux)", "screencapture (macOS)"]
|
||||
},
|
||||
"skill_search_hints": ["screen recording", "capture", "screencast"]
|
||||
},
|
||||
{
|
||||
"name": "Audio capture/edit",
|
||||
"goal": "Record, clean, trim, normalize, and process audio tracks",
|
||||
"clis": [
|
||||
"audacity"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["pydub", "soundfile", "librosa", "noisereduce"],
|
||||
"native": ["sox", "ffmpeg"]
|
||||
},
|
||||
"skill_search_hints": ["audio editing", "noise reduction", "audio processing"]
|
||||
},
|
||||
{
|
||||
"name": "Voice/TTS",
|
||||
"goal": "Generate speech audio from text for narration or voiceover",
|
||||
"clis": [
|
||||
"minimax-cli",
|
||||
"elevenlabs"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["TTS (coqui-ai)", "pyttsx3", "edge-tts"],
|
||||
"api": ["Google Cloud TTS", "Amazon Polly", "Azure Speech"]
|
||||
},
|
||||
"skill_search_hints": ["text-to-speech", "TTS", "voice synthesis", "narration"]
|
||||
},
|
||||
{
|
||||
"name": "Music/BGM gen",
|
||||
"goal": "Generate background music or sound effects from prompts",
|
||||
"clis": [
|
||||
"minimax-cli",
|
||||
"suno"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["audiocraft (Meta MusicGen)", "stable-audio-tools"],
|
||||
"api": ["Suno API", "Udio API"]
|
||||
},
|
||||
"skill_search_hints": ["music generation", "background music", "audio synthesis"]
|
||||
},
|
||||
{
|
||||
"name": "NLE editing",
|
||||
"goal": "Assemble video/audio clips on a timeline, add transitions, and export the final cut",
|
||||
"clis": [
|
||||
"kdenlive",
|
||||
"shotcut"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["moviepy", "ffmpeg-python"],
|
||||
"native": ["ffmpeg concat/filter_complex"]
|
||||
},
|
||||
"skill_search_hints": ["video editing", "timeline", "NLE", "video assembly"]
|
||||
},
|
||||
{
|
||||
"name": "Captions/subs",
|
||||
"goal": "Transcribe speech, generate subtitles (SRT/ASS), optionally translate, and burn into video",
|
||||
"clis": [
|
||||
"videocaptioner"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["whisper (openai-whisper)", "pysrt", "stable-ts"],
|
||||
"api": ["Google Speech-to-Text", "AssemblyAI", "Deepgram"],
|
||||
"native": ["ffmpeg -vf subtitles=subs.srt"]
|
||||
},
|
||||
"skill_search_hints": ["subtitle", "transcribe", "caption", "speech-to-text"]
|
||||
},
|
||||
{
|
||||
"name": "Thumbnail",
|
||||
"goal": "Create a thumbnail image or social card for the video",
|
||||
"clis": [
|
||||
"gimp"
|
||||
],
|
||||
"alternatives": {
|
||||
"python": ["Pillow", "cairosvg", "html2image"],
|
||||
"native": ["ffmpeg -ss 00:01:00 -frames:v 1 thumb.png", "ImageMagick convert"]
|
||||
},
|
||||
"skill_search_hints": ["image editing", "thumbnail", "graphic design"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user