diff --git a/.github/scripts/generate_meta_skill.py b/.github/scripts/generate_meta_skill.py index e6e0d2ec5..f2f28ba03 100644 --- a/.github/scripts/generate_meta_skill.py +++ b/.github/scripts/generate_meta_skill.py @@ -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 [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() diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index f47848ba8..cf0fd9d1e 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -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: diff --git a/cli-hub-matrix/video-creation/SKILL.md b/cli-hub-matrix/video-creation/SKILL.md new file mode 100644 index 000000000..7af3bef5c --- /dev/null +++ b/cli-hub-matrix/video-creation/SKILL.md @@ -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 ""` 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. diff --git a/cli-hub/cli_hub/cli.py b/cli-hub/cli_hub/cli.py index de922f073..428d39f67 100644 --- a/cli-hub/cli_hub/cli.py +++ b/cli-hub/cli_hub/cli.py @@ -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() diff --git a/cli-hub/cli_hub/installer.py b/cli-hub/cli_hub/installer.py index 0a7451c48..ec2dfe83d 100644 --- a/cli-hub/cli_hub/installer.py +++ b/cli-hub/cli_hub/installer.py @@ -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 diff --git a/cli-hub/cli_hub/matrix.py b/cli-hub/cli_hub/matrix.py new file mode 100644 index 000000000..c3aae017e --- /dev/null +++ b/cli-hub/cli_hub/matrix.py @@ -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 diff --git a/cli-hub/cli_hub/matrix_skill.py b/cli-hub/cli_hub/matrix_skill.py new file mode 100644 index 000000000..bfd148706 --- /dev/null +++ b/cli-hub/cli_hub/matrix_skill.py @@ -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\n\n{injected_section}\n\n\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) diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index 22f87c1f0..4b9d5fba4 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -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") diff --git a/matrix_registry.json b/matrix_registry.json new file mode 100644 index 000000000..193cbc8a4 --- /dev/null +++ b/matrix_registry.json @@ -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"] + } + ] + } + ] +}