diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 564a10ff0..d10f0d301 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,7 +22,8 @@ Fixes # - [ ] `.md` SOP document exists at `/agent-harness/.md` -- [ ] `SKILL.md` exists inside the Python package (`cli_anything//SKILL.md`) +- [ ] Canonical `SKILL.md` exists at `skills/cli-anything-/SKILL.md` +- [ ] Packaged compatibility `SKILL.md` exists at `cli_anything//skills/SKILL.md` - [ ] Unit tests at `cli_anything//tests/test_core.py` are present and pass without backend - [ ] E2E tests at `cli_anything//tests/test_full_e2e.py` are present - [ ] `README.md` includes the new software (with link to harness directory) diff --git a/.github/scripts/sync_root_skills.py b/.github/scripts/sync_root_skills.py new file mode 100644 index 000000000..f22745310 --- /dev/null +++ b/.github/scripts/sync_root_skills.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Sync repo-root skills/ from harness-local SKILL.md files.""" + +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ROOT_SKILLS_DIR = REPO_ROOT / "skills" + + +def _canonical_skill_id(source: Path) -> str: + rel = source.relative_to(REPO_ROOT) + software_dir = rel.parts[0] + return f"cli-anything-{software_dir.replace('_', '-')}" + + +def _rewrite_name_frontmatter(content: str, skill_id: str) -> str: + if not content.startswith("---\n"): + return content + + parts = content.split("---\n", 2) + if len(parts) < 3: + return content + + _, frontmatter, body = parts + lines = frontmatter.splitlines(keepends=True) + rewritten: list[str] = [] + replaced = False + i = 0 + while i < len(lines): + line = lines[i] + if not replaced and line.startswith("name:"): + rewritten.append(f'name: "{skill_id}"\n') + replaced = True + i += 1 + while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t")): + i += 1 + continue + rewritten.append(line) + i += 1 + + if not replaced: + rewritten.insert(0, f'name: "{skill_id}"\n') + + frontmatter = "".join(rewritten) + return f"---\n{frontmatter}---\n{body}" + + +def _discover_sources() -> list[Path]: + sources: list[Path] = [] + sources.extend(sorted(REPO_ROOT.glob("*/agent-harness/cli_anything/*/skills/SKILL.md"))) + sources.extend(sorted(REPO_ROOT.glob("*/agent-harness/cli_anything/*/SKILL.md"))) + return [path for path in sources if path.is_file()] + + +def main() -> int: + sources = _discover_sources() + ROOT_SKILLS_DIR.mkdir(parents=True, exist_ok=True) + + for source in sources: + skill_id = _canonical_skill_id(source) + target = ROOT_SKILLS_DIR / skill_id / "SKILL.md" + target.parent.mkdir(parents=True, exist_ok=True) + content = source.read_text(encoding="utf-8") + target.write_text(_rewrite_name_frontmatter(content, skill_id), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/validate_root_skills.py b/.github/scripts/validate_root_skills.py new file mode 100644 index 000000000..818fd8743 --- /dev/null +++ b/.github/scripts/validate_root_skills.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Validate that deep harness SKILL.md files are mirrored in repo-root skills/.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_sync_helpers(): + namespace: dict[str, object] = {"__file__": str(REPO_ROOT / ".github" / "scripts" / "sync_root_skills.py")} + sync_script = REPO_ROOT / ".github" / "scripts" / "sync_root_skills.py" + exec(sync_script.read_text(encoding="utf-8"), namespace) + return namespace + + +def main() -> int: + sync = _load_sync_helpers() + discover_sources = sync["_discover_sources"] + canonical_skill_id = sync["_canonical_skill_id"] + rewrite_name_frontmatter = sync["_rewrite_name_frontmatter"] + root_skills_dir = sync["ROOT_SKILLS_DIR"] + + errors: list[str] = [] + for source in discover_sources(): + skill_id = canonical_skill_id(source) + target = root_skills_dir / skill_id / "SKILL.md" + if not target.is_file(): + errors.append( + f"Missing root skill for {source.relative_to(REPO_ROOT)}: expected {target.relative_to(REPO_ROOT)}" + ) + continue + + source_content = source.read_text(encoding="utf-8") + expected = rewrite_name_frontmatter(source_content, skill_id) + actual = target.read_text(encoding="utf-8") + if actual != expected: + errors.append( + f"Out-of-sync root skill for {source.relative_to(REPO_ROOT)}: {target.relative_to(REPO_ROOT)}" + ) + + if errors: + print("Root skills validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + print( + "Run `python3 .github/scripts/sync_root_skills.py` and commit the updated root skills.", + file=sys.stderr, + ) + return 1 + + print("Root skills validation passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/check-root-skills.yml b/.github/workflows/check-root-skills.yml new file mode 100644 index 000000000..024017297 --- /dev/null +++ b/.github/workflows/check-root-skills.yml @@ -0,0 +1,35 @@ +name: Check Root Skills + +on: + pull_request: + paths: + - '*/agent-harness/**' + - 'skills/**' + - '.github/scripts/sync_root_skills.py' + - '.github/scripts/validate_root_skills.py' + - '.github/workflows/check-root-skills.yml' + push: + branches: + - main + paths: + - '*/agent-harness/**' + - 'skills/**' + - '.github/scripts/sync_root_skills.py' + - '.github/scripts/validate_root_skills.py' + - '.github/workflows/check-root-skills.yml' + workflow_dispatch: + +jobs: + validate-root-skills: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Validate root skills mirror + run: python3 .github/scripts/validate_root_skills.py diff --git a/.gitignore b/.gitignore index eec2cff55..deb896a76 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,9 @@ !/openclaw-skill/ !/cli-hub-meta-skill/ !/cli-hub/ +!/cli-hub-matrix/ +!/skills/ +!/skills/** # Ignore cli-hub-skill (auto-generated, not tracked) /cli-hub-skill/ @@ -92,7 +95,6 @@ !/renderdoc/ !/cloudcompare/ !/openscreen/ -!/QGIS/ !/n8n/ !/obsidian/ @@ -165,8 +167,6 @@ /cloudcompare/.* /openscreen/* /openscreen/.* -/QGIS/* -/QGIS/.* /cloudanalyzer/* /cloudanalyzer/.* /wiremock/* @@ -220,7 +220,6 @@ !/wiremock/ !/wiremock/agent-harness/ !/exa/agent-harness/ -!/QGIS/agent-harness/ !/n8n/agent-harness/ !/obsidian/agent-harness/ !/safari/ @@ -251,6 +250,7 @@ assets/gen_typing_gif.py # Step 10: Allow CLI Hub registry and frontend !/registry.json !/public_registry.json +!/matrix_registry.json !/docs/ /docs/* !/docs/hub/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b6bc79be..a485a8962 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Adding a new CLI harness is the most impactful contribution. You can either add Place your code under `/agent-harness/` and ensure the following: 1. **`.md`** — the SOP document exists at `/agent-harness/.md` describing the harness architecture. -2. **`SKILL.md`** — the AI-discoverable skill definition exists inside the Python package at `cli_anything//skills/SKILL.md`. +2. **`SKILL.md`** — the canonical AI-discoverable skill definition exists at `skills/cli-anything-/SKILL.md`, and the packaged compatibility copy exists at `cli_anything//skills/SKILL.md`. 3. **Tests** — unit tests (`test_core.py`, passable without backend) and E2E tests (`test_full_e2e.py`) are present and passing. 4. **`README.md`** — the project README includes the new software with a link to its harness directory. 5. **`registry.json`** — add an entry for the new software (see [Registry fields](#registry-fields) below). @@ -30,7 +30,7 @@ Requirements for standalone CLIs: 1. **Published package** — your CLI must be installable via `pip install ` (PyPI) or a `pip install git+https://...` URL. 2. **`SKILL.md`** — an AI-discoverable skill definition exists somewhere in your repo. 3. **Tests** — your repo should have its own test suite. -4. **`registry.json`** — add an entry with `source_url` pointing to your repo and `skill_md` pointing to the full URL of your SKILL.md (see [Registry fields](#registry-fields) below). +4. **`registry.json`** — add an entry with `source_url` pointing to your repo and `skill_md` pointing to the raw URL of your SKILL.md (see [Registry fields](#registry-fields) below). ### B) New Features @@ -67,7 +67,7 @@ Include an entry in `registry.json` as part of your PR. Each field is described | `source_url` | Yes | For standalone repos: URL to your repo (e.g. `"https://github.com/user/repo"`). For in-repo harnesses: `null` (the hub auto-links to `/agent-harness/`). | | `install_cmd` | Yes | Full pip install command. PyPI: `"pip install cli-anything-my-software"`. In-repo: `"pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=my-software/agent-harness"`. | | `entry_point` | Yes | CLI command name (e.g. `"cli-anything-my-software"`). | -| `skill_md` | Yes | Path to SKILL.md. For standalone repos: full URL (e.g. `"https://github.com/user/repo/blob/main/.../SKILL.md"`). For in-repo: relative path (e.g. `"my-software/agent-harness/cli_anything/my_software/skills/SKILL.md"`). Set to `null` if not yet available. | +| `skill_md` | Yes | Path to canonical SKILL.md. For standalone repos: full URL (e.g. `"https://github.com/user/repo/blob/main/.../SKILL.md"`). For in-repo: relative path under the repo-root `skills/` tree (e.g. `"skills/cli-anything-my-software/SKILL.md"`). Set to `null` if not yet available. | | `category` | Yes | One of the existing categories (check `registry.json` for examples). | | `contributors` | Yes | Array of `{"name": "...", "url": "..."}` objects listing all contributors. | @@ -84,7 +84,7 @@ Include an entry in `registry.json` as part of your PR. Each field is described "source_url": null, "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=my-software/agent-harness", "entry_point": "cli-anything-my-software", - "skill_md": "my-software/agent-harness/cli_anything/my_software/skills/SKILL.md", + "skill_md": "skills/cli-anything-my-software/SKILL.md", "category": "category-name", "contributors": [ {"name": "your-github-username", "url": "https://github.com/your-github-username"} diff --git a/README.md b/README.md index 5790e56ca..f49467e80 100644 --- a/README.md +++ b/README.md @@ -502,7 +502,7 @@ cli-anything-gimp --json layer add -n "Background" --type solid --color "#1a1a2e cli-anything-gimp ``` -Each installed CLI ships with a [`SKILL.md`](#-skillmd-generation) inside the Python package (`cli_anything//skills/SKILL.md`). The REPL banner automatically displays the absolute path to this file so AI agents know exactly where to read the skill definition. No extra configuration needed — `pip install` makes the skill discoverable. +Each in-repo harness now has a canonical [`SKILL.md`](#-skillmd-generation) at `skills/cli-anything-/SKILL.md`, which makes the monorepo directly discoverable via `npx skills add HKUDS/CLI-Anything --list`. Installed harness packages still ship a compatibility copy at `cli_anything//skills/SKILL.md`, and the REPL banner prefers the repo-root canonical file when present, falling back to the packaged copy otherwise. --- @@ -538,7 +538,7 @@ The agent will browse the catalog, install whichever CLI fits the task, and use The catalog auto-updates whenever `registry.json` changes — new community CLIs show up automatically. -> **For Claude Code users:** Copy [`cli-hub-meta-skill/SKILL.md`](cli-hub-meta-skill/SKILL.md) into your project or skills directory for the same automatic CLI discovery. +> **For Claude Code users:** Copy [`skills/cli-hub-meta-skill/SKILL.md`](skills/cli-hub-meta-skill/SKILL.md) into your project or skills directory for the same automatic CLI discovery. --- @@ -671,7 +671,7 @@ All CLIs organized under cli_anything.* namespace — conflict-free, pip-install ### 🤖 SKILL.md Generation -Each generated CLI includes a `SKILL.md` file inside the Python package at `cli_anything//skills/SKILL.md`. This self-contained skill definition enables AI agents to discover and use the CLI through Claude Code's skill system or other agent frameworks. +Each generated CLI now has a canonical `SKILL.md` at `skills/cli-anything-/SKILL.md`. This makes the current monorepo directly consumable by `npx skills`, while a packaged compatibility copy at `cli_anything//skills/SKILL.md` preserves installed-harness behavior. **What SKILL.md provides:** - **YAML frontmatter** with name and description for agent skill discovery @@ -679,7 +679,7 @@ Each generated CLI includes a `SKILL.md` file inside the Python package at `cli_ - **Usage examples** for common workflows - **Agent-specific guidance** for JSON output, error handling, and programmatic use -SKILL.md files are auto-generated during Phase 6.5 of the pipeline using `skill_generator.py`, which extracts metadata directly from the CLI's Click decorators, setup.py, and README. Because the file lives inside the package, it is installed alongside the CLI via `pip install` and auto-detected by the REPL banner — agents can read the absolute path displayed at startup. +SKILL.md files are auto-generated during Phase 6.5 of the pipeline using `skill_generator.py`, which extracts metadata directly from the CLI's Click decorators, setup.py, and README. The generator now writes the canonical repo-root skill file and refreshes the package-local compatibility copy used by installed harnesses. Inside this repo, the REPL banner points agents to the canonical root skill path; after `pip install`, it falls back to the packaged copy. --- diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py b/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py index 47260bebd..bc1fb6d1d 100644 --- a/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py +++ b/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -97,7 +111,7 @@ class ReplSkin: """ def __init__(self, software: str, version: str = "1.0.0", - history_file: str | None = None): + history_file: str | None = None, skill_path: str | None = None): """Initialize the REPL skin. Args: @@ -105,15 +119,45 @@ class ReplSkin: version: CLI version string. history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -143,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -151,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -165,9 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py b/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py index a444e0458..bc1fb6d1d 100644 --- a/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py +++ b/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -47,7 +48,6 @@ _ACCENT_COLORS = { "obs_studio": "\033[38;5;55m", # purple "kdenlive": "\033[38;5;69m", # slate blue "shotcut": "\033[38;5;35m", # teal green - "anygen": "\033[38;5;141m", # soft violet } _DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue @@ -58,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -90,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -98,7 +111,7 @@ class ReplSkin: """ def __init__(self, software: str, version: str = "1.0.0", - history_file: str | None = None): + history_file: str | None = None, skill_path: str | None = None): """Initialize the REPL skin. Args: @@ -106,15 +119,45 @@ class ReplSkin: version: CLI version string. history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -144,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -152,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -166,9 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) @@ -494,7 +562,6 @@ _ANSI_256_TO_HEX = { "\033[38;5;69m": "#5f87ff", # kdenlive slate blue "\033[38;5;75m": "#5fafff", # default sky blue "\033[38;5;80m": "#5fd7d7", # brand cyan - "\033[38;5;141m": "#af87ff", # anygen soft violet "\033[38;5;208m": "#ff8700", # blender deep orange "\033[38;5;214m": "#ffaf00", # gimp warm orange } diff --git a/audacity/agent-harness/cli_anything/audacity/utils/repl_skin.py b/audacity/agent-harness/cli_anything/audacity/utils/repl_skin.py index 47260bebd..bc1fb6d1d 100644 --- a/audacity/agent-harness/cli_anything/audacity/utils/repl_skin.py +++ b/audacity/agent-harness/cli_anything/audacity/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -97,7 +111,7 @@ class ReplSkin: """ def __init__(self, software: str, version: str = "1.0.0", - history_file: str | None = None): + history_file: str | None = None, skill_path: str | None = None): """Initialize the REPL skin. Args: @@ -105,15 +119,45 @@ class ReplSkin: version: CLI version string. history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -143,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -151,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -165,9 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/blender/agent-harness/cli_anything/blender/utils/repl_skin.py b/blender/agent-harness/cli_anything/blender/utils/repl_skin.py index 47260bebd..bc1fb6d1d 100644 --- a/blender/agent-harness/cli_anything/blender/utils/repl_skin.py +++ b/blender/agent-harness/cli_anything/blender/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -97,7 +111,7 @@ class ReplSkin: """ def __init__(self, software: str, version: str = "1.0.0", - history_file: str | None = None): + history_file: str | None = None, skill_path: str | None = None): """Initialize the REPL skin. Args: @@ -105,15 +119,45 @@ class ReplSkin: version: CLI version string. history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -143,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -151,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -165,9 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/browser/agent-harness/cli_anything/browser/utils/repl_skin.py b/browser/agent-harness/cli_anything/browser/utils/repl_skin.py index dab3acb5a..bc1fb6d1d 100644 --- a/browser/agent-harness/cli_anything/browser/utils/repl_skin.py +++ b/browser/agent-harness/cli_anything/browser/utils/repl_skin.py @@ -6,20 +6,21 @@ Copy this file into your CLI package at: Usage: from cli_anything..utils.repl_skin import ReplSkin - skin = ReplSkin("browser", version="1.0.0") - skin.print_banner() - prompt_text = skin.prompt(project_name="https://example.com", modified=False) - skin.success("Page loaded") - skin.error("Connection failed") - skin.warning("DOMShell not found") - skin.info("Navigating...") - skin.status("URL", "https://example.com") + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects repo-root or packaged SKILL.md + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") skin.table(headers, rows) skin.print_goodbye() """ import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -47,8 +48,6 @@ _ACCENT_COLORS = { "obs_studio": "\033[38;5;55m", # purple "kdenlive": "\033[38;5;69m", # slate blue "shotcut": "\033[38;5;35m", # teal green - "ollama": "\033[38;5;255m", # white (Ollama branding) - "browser": "\033[38;5;141m", # lavender (browser harness) } _DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue @@ -59,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -91,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -99,23 +111,53 @@ class ReplSkin: """ def __init__(self, software: str, version: str = "1.0.0", - history_file: str | None = None): + history_file: str | None = None, skill_path: str | None = None): """Initialize the REPL skin. Args: - software: Software name (e.g., "gimp", "shotcut", "browser"). + software: Software name (e.g., "gimp", "shotcut", "blender"). version: CLI version string. history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -145,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -153,10 +197,28 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") - # Title: ◆ cli-anything · Browser + # Title: ◆ cli-anything · Shotcut icon = self._c(_CYAN + _BOLD, "◆") brand = self._c(_CYAN + _BOLD, "cli-anything") dot = self._c(_DARK_GRAY, "·") @@ -167,9 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) @@ -301,7 +368,7 @@ class ReplSkin: print(f" {self._c(self.accent + _BOLD, title)}") print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") - # ── Status display ─────────────────────────────────────────────── + # ── Status display ──────────────────────────────────────────────── def status(self, label: str, value: str): """Print a key-value status line.""" @@ -495,8 +562,6 @@ _ANSI_256_TO_HEX = { "\033[38;5;69m": "#5f87ff", # kdenlive slate blue "\033[38;5;75m": "#5fafff", # default sky blue "\033[38;5;80m": "#5fd7d7", # brand cyan - "\033[38;5;141m": "#afafff", # browser lavender "\033[38;5;208m": "#ff8700", # blender deep orange "\033[38;5;214m": "#ffaf00", # gimp warm orange - "\033[38;5;255m": "#eeeeee", # ollama white } diff --git a/chromadb/agent-harness/cli_anything/chromadb/utils/repl_skin.py b/chromadb/agent-harness/cli_anything/chromadb/utils/repl_skin.py index b356cb835..bc1fb6d1d 100644 --- a/chromadb/agent-harness/cli_anything/chromadb/utils/repl_skin.py +++ b/chromadb/agent-harness/cli_anything/chromadb/utils/repl_skin.py @@ -6,20 +6,21 @@ Copy this file into your CLI package at: Usage: from cli_anything..utils.repl_skin import ReplSkin - skin = ReplSkin("ollama", version="1.0.0") - skin.print_banner() - prompt_text = skin.prompt(project_name="llama3.2", modified=False) - skin.success("Model pulled") - skin.error("Connection failed") - skin.warning("No models loaded") - skin.info("Generating...") - skin.status("Model", "llama3.2:latest") + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects repo-root or packaged SKILL.md + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") skin.table(headers, rows) skin.print_goodbye() """ import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -47,7 +48,6 @@ _ACCENT_COLORS = { "obs_studio": "\033[38;5;55m", # purple "kdenlive": "\033[38;5;69m", # slate blue "shotcut": "\033[38;5;35m", # teal green - "ollama": "\033[38;5;255m", # white (Ollama branding) } _DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue @@ -58,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -90,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -98,23 +111,53 @@ class ReplSkin: """ def __init__(self, software: str, version: str = "1.0.0", - history_file: str | None = None): + history_file: str | None = None, skill_path: str | None = None): """Initialize the REPL skin. Args: - software: Software name (e.g., "gimp", "shotcut", "ollama"). + software: Software name (e.g., "gimp", "shotcut", "blender"). version: CLI version string. history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -144,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -152,10 +197,28 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") - # Title: ◆ cli-anything · Ollama + # Title: ◆ cli-anything · Shotcut icon = self._c(_CYAN + _BOLD, "◆") brand = self._c(_CYAN + _BOLD, "cli-anything") dot = self._c(_DARK_GRAY, "·") @@ -166,9 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) @@ -496,5 +564,4 @@ _ANSI_256_TO_HEX = { "\033[38;5;80m": "#5fd7d7", # brand cyan "\033[38;5;208m": "#ff8700", # blender deep orange "\033[38;5;214m": "#ffaf00", # gimp warm orange - "\033[38;5;255m": "#eeeeee", # ollama white } diff --git a/cli-anything-plugin/HARNESS.md b/cli-anything-plugin/HARNESS.md index 1a08bea0d..b96751cd8 100644 --- a/cli-anything-plugin/HARNESS.md +++ b/cli-anything-plugin/HARNESS.md @@ -79,7 +79,7 @@ designed for humans, without needing a display or mouse. from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("", version="1.0.0") - skin.print_banner() # Branded startup box (auto-detects skills/SKILL.md) + skin.print_banner() # Branded startup box (prefers repo-root skills/, falls back to package) pt_session = skin.create_prompt_session() # prompt_toolkit with history + styling line = skin.get_input(pt_session, project_name="my_project", modified=True) skin.help(commands_dict) # Formatted help listing @@ -92,8 +92,10 @@ designed for humans, without needing a display or mouse. skin.progress(3, 10, "...") # Progress bar skin.print_goodbye() # Styled exit message ``` - - ReplSkin auto-detects `skills/SKILL.md` inside the package directory and displays - it in the banner. AI agents can read the skill file at the displayed absolute path. + - ReplSkin prefers the repo-root canonical `skills/cli-anything-/SKILL.md` + when running inside this monorepo, and falls back to the packaged + `cli_anything//skills/SKILL.md` copy when installed elsewhere. + AI agents can read the skill file at the displayed absolute path. - Make REPL the default behavior: use `invoke_without_command=True` on the main Click group, and invoke the `repl` command when no subcommand is given: ```python @@ -257,8 +259,10 @@ automatically, or customize via the Jinja2 template at `templates/SKILL.md.templ See [`guides/skill-generation.md`](guides/skill-generation.md) for the full generation process, template customization options, and manual generation commands. -**Output Location:** SKILL.md lives inside the Python package at -`cli_anything//skills/SKILL.md` so it is installed with `pip install`. +**Output Location:** The canonical skill lives at +`skills/cli-anything-/SKILL.md`. A compatibility copy is also written to +`cli_anything//skills/SKILL.md` so installed harnesses still ship a +local skill file. **Key Principles:** @@ -270,9 +274,9 @@ process, template customization options, and manual generation commands. **Skill Path in CLI Banner:** -ReplSkin auto-detects `skills/SKILL.md` inside the package directory and displays -the absolute path in the startup banner. AI agents can read the skill file at the -displayed path to learn the CLI's full capabilities. +ReplSkin prefers the repo-root canonical skill path and falls back to the +packaged `skills/SKILL.md` copy. AI agents can read the displayed path to learn +the CLI's full capabilities. **Package Data:** Ensure `setup.py` includes the skill file so it ships with pip: diff --git a/cli-anything-plugin/README.md b/cli-anything-plugin/README.md index 5b223a39b..c5d58f0b8 100644 --- a/cli-anything-plugin/README.md +++ b/cli-anything-plugin/README.md @@ -219,7 +219,8 @@ Generate AI-discoverable skill definition: - Extract CLI metadata using `skill_generator.py` - Generate SKILL.md with YAML frontmatter (name, description) - Include command groups, examples, and agent-specific guidance -- Output to `cli_anything//skills/SKILL.md` (inside the Python package) +- Output canonical skill to `skills/cli-anything-/SKILL.md` +- Refresh package-local compatibility copy at `cli_anything//skills/SKILL.md` **Output:** SKILL.md file for AI agent discovery @@ -237,6 +238,10 @@ Package and install: ## Output Structure ``` +skills/ +└── cli-anything-/ + └── SKILL.md # Canonical repo-root skill for npx skills discovery + / └── agent-harness/ ├── .md # Software-specific SOP @@ -253,8 +258,6 @@ Package and install: │ ├── session.py # Undo/redo │ ├── export.py # Rendering/export │ └── ... # Domain-specific modules - ├── skills/ # AI-discoverable skill definition - │ └── SKILL.md # Installed with the package via package_data ├── utils/ # Utilities │ ├── __init__.py │ ├── repl_skin.py # Unified REPL skin (copy from plugin) @@ -323,6 +326,7 @@ The cli-anything methodology has successfully built CLIs for: - YAML frontmatter with name and description for triggering - Command groups and usage examples - Agent-specific guidance for programmatic usage +- Canonical repo-root `skills/` layout for `npx skills` discovery - Follows skill-creator methodology ### PyPI Distribution diff --git a/cli-anything-plugin/commands/cli-anything.md b/cli-anything-plugin/commands/cli-anything.md index 05ed8a710..9d61199f7 100644 --- a/cli-anything-plugin/commands/cli-anything.md +++ b/cli-anything-plugin/commands/cli-anything.md @@ -73,7 +73,7 @@ This command implements the complete cli-anything methodology to build a product - Extracts CLI metadata using `skill_generator.py` - Generates SKILL.md with YAML frontmatter and Markdown body - Includes command groups, examples, and agent-specific guidance -- Outputs to `cli_anything//skills/SKILL.md` inside the Python package +- Outputs the canonical skill to `skills/cli-anything-/SKILL.md` and refreshes the packaged compatibility copy at `cli_anything//skills/SKILL.md` - Makes the CLI discoverable and usable by AI agents ### Phase 7: PyPI Publishing and Installation @@ -100,8 +100,6 @@ This command implements the complete cli-anything methodology to build a product │ ├── session.py │ ├── export.py │ └── ... - ├── skills/ - │ └── SKILL.md # AI-discoverable skill definition ├── utils/ # Utilities └── tests/ ├── TEST.md # Test plan and results @@ -109,6 +107,14 @@ This command implements the complete cli-anything methodology to build a product └── test_full_e2e.py # E2E tests ``` +Canonical repo-root skill output: + +``` +skills/ +└── cli-anything-/ + └── SKILL.md +``` + ## Example ```bash diff --git a/cli-anything-plugin/guides/skill-generation.md b/cli-anything-plugin/guides/skill-generation.md index ea3f02a77..cf5fd0702 100644 --- a/cli-anything-plugin/guides/skill-generation.md +++ b/cli-anything-plugin/guides/skill-generation.md @@ -41,7 +41,7 @@ from skill_generator import generate_skill_file skill_path = generate_skill_file( harness_path="/path/to/agent-harness" ) -# Default output: cli_anything//skills/SKILL.md +# Default output: skills/cli-anything-/SKILL.md ``` ### 2. The generator automatically extracts: @@ -59,9 +59,14 @@ skill_path = generate_skill_file( ## Output Location -SKILL.md is generated inside the Python package so it is installed with `pip install`: +SKILL.md is generated canonically at the repo root, with a packaged compatibility +copy for installed harnesses: ``` +skills/ +└── cli-anything-/ + └── SKILL.md + / └── agent-harness/ └── cli_anything/ @@ -93,15 +98,16 @@ skill definition. ## Skill Path in CLI Banner -ReplSkin auto-detects `skills/SKILL.md` inside the package and displays the absolute -path in the startup banner. AI agents can read the file at the displayed path: +ReplSkin prefers the repo-root canonical skill file and falls back to the +packaged copy, displaying whichever absolute path is available in the startup +banner. AI agents can read the file at the displayed path: ```python # In the REPL initialization (e.g., shotcut_cli.py) from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("", version="1.0.0") -skin.print_banner() # Auto-detects and displays: ◇ Skill: /path/to/cli_anything//skills/SKILL.md +skin.print_banner() # Displays repo-root skills/cli-anything-/SKILL.md when available ``` ## Package Data diff --git a/cli-anything-plugin/repl_skin.py b/cli-anything-plugin/repl_skin.py index c7312348a..bc1fb6d1d 100644 --- a/cli-anything-plugin/repl_skin.py +++ b/cli-anything-plugin/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() # auto-detects skills/SKILL.md inside the package + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -106,27 +120,44 @@ class ReplSkin: history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history skill_path: Path to the SKILL.md file for agent discovery. - Auto-detected from the package's skills/ directory if not provided. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") - # Auto-detect skill path from package layout: - # cli_anything//utils/repl_skin.py (this file) - # cli_anything//skills/SKILL.md (target) + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. if skill_path is None: - from pathlib import Path - _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" - if _auto.is_file(): - skill_path = str(_auto) + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -156,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -164,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -178,19 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" - # Skill path for agent discovery - skill_line = None - if self.skill_path: - skill_icon = self._c(_MAGENTA, "◇") - skill_label = self._c(_DARK_GRAY, " Skill:") - skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) - skill_line = f" {skill_icon} {skill_label} {skill_path_display}" - + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) - if skill_line: - print(_box_line(skill_line)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/cli-anything-plugin/skill_generator.py b/cli-anything-plugin/skill_generator.py index de2d9a6a7..ecbf47df9 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -22,6 +22,12 @@ def _format_display_name(name: str) -> str: return name.replace("_", " ").replace("-", " ").title() +def _canonical_skill_name(harness_path: Path, software_name: str) -> str: + """Return the repo-root canonical skill id for a harness.""" + software_dir = harness_path.parent.name if harness_path.parent.name else software_name + return f"cli-anything-{software_dir.replace('_', '-')}" + + @dataclass class CommandInfo: """Information about a CLI command.""" @@ -114,13 +120,8 @@ def extract_cli_metadata(harness_path: str) -> SkillMetadata: examples = generate_examples(software_name, command_groups) # Build skill name and description - skill_name = f"cli-anything-{software_name}" - if skill_intro: - intro_snippet = skill_intro[:100] - suffix = "..." if len(skill_intro) > 100 else "" - skill_description = f"Command-line interface for {_format_display_name(software_name)} - {intro_snippet}{suffix}" - else: - skill_description = f"Command-line interface for {_format_display_name(software_name)}" + skill_name = _canonical_skill_name(harness_path, software_name) + skill_description = f"Command-line interface for {_format_display_name(software_name)} - {skill_intro[:100]}..." return SkillMetadata( skill_name=skill_name, @@ -164,16 +165,14 @@ def extract_system_package(content: str) -> Optional[str]: patterns = [ r"`apt install ([\w\-]+)`", r"`brew install ([\w\-]+)`", - r"`apt-get install ([\w\-]+)`", + r"apt-get install ([\w\-]+)", ] for pattern in patterns: match = re.search(pattern, content) if match: package = match.group(1) - if "apt-get" in pattern: - return f"apt-get install {package}" - elif "apt" in pattern: + if "apt" in pattern: return f"apt install {package}" elif "brew" in pattern: return f"brew install {package}" @@ -472,7 +471,8 @@ def generate_skill_file(harness_path: str, output_path: Optional[str] = None, Args: harness_path: Path to the agent-harness directory - output_path: Optional output path for SKILL.md (default: cli_anything//skills/SKILL.md) + output_path: Optional output path for SKILL.md + (default: skills/cli-anything-/SKILL.md) template_path: Optional path to custom Jinja2 template Returns: @@ -485,10 +485,11 @@ def generate_skill_file(harness_path: str, output_path: Optional[str] = None, content = generate_skill_md(metadata, template_path) # Determine output path + harness_path_obj = Path(harness_path) + compatibility_path = harness_path_obj / "cli_anything" / metadata.software_name / "skills" / "SKILL.md" if output_path is None: - # Default to skills/ directory under harness_path - harness_path_obj = Path(harness_path) - output_path = harness_path_obj / "cli_anything" / metadata.software_name / "skills" / "SKILL.md" + repo_root = harness_path_obj.parent.parent + output_path = repo_root / "skills" / metadata.skill_name / "SKILL.md" else: output_path = Path(output_path) @@ -497,6 +498,9 @@ def generate_skill_file(harness_path: str, output_path: Optional[str] = None, # Write file output_path.write_text(content, encoding="utf-8") + if compatibility_path != output_path: + compatibility_path.parent.mkdir(parents=True, exist_ok=True) + compatibility_path.write_text(content, encoding="utf-8") return str(output_path) @@ -514,7 +518,7 @@ if __name__ == "__main__": ) parser.add_argument( "-o", "--output", - help="Output path for SKILL.md (default: cli_anything//skills/SKILL.md)", + help="Output path for SKILL.md (default: skills/cli-anything-/SKILL.md)", default=None ) parser.add_argument( diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index 71006c20e..f1f6f4f07 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": None, + "skill_md": "skills/cli-anything-gimp/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 ─────────────────────────────────────────────────── @@ -153,6 +189,137 @@ class TestRegistry: 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 "skills/cli-anything-gimp/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 ────────────────────────────────────────────────── @@ -485,6 +652,26 @@ 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 ────────────────────────────────────────────────── @@ -644,6 +831,81 @@ 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_is_agent", return_value=False) + @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): + 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_is_agent", return_value=False) + @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): + 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_is_agent", return_value=False) + @patch("cli_hub.cli.search_matrices", return_value=[]) + def test_matrix_search_no_results(self, mock_search, mock_detect, mock_visit, mock_first_run): + 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_is_agent", return_value=False) + @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): + 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_is_agent", return_value=False) + @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): + 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_is_agent", return_value=False) + @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): + 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_is_agent", return_value=False) @@ -686,6 +948,7 @@ class TestCLI: result = self.runner.invoke(main, ["info", "gimp"]) assert "GIMP" in result.output assert "image" in result.output + assert "Skill:" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py index c7312348a..bc1fb6d1d 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() # auto-detects skills/SKILL.md inside the package + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -106,27 +120,44 @@ class ReplSkin: history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history skill_path: Path to the SKILL.md file for agent discovery. - Auto-detected from the package's skills/ directory if not provided. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") - # Auto-detect skill path from package layout: - # cli_anything//utils/repl_skin.py (this file) - # cli_anything//skills/SKILL.md (target) + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. if skill_path is None: - from pathlib import Path - _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" - if _auto.is_file(): - skill_path = str(_auto) + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -156,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -164,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -178,19 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" - # Skill path for agent discovery - skill_line = None - if self.skill_path: - skill_icon = self._c(_MAGENTA, "◇") - skill_label = self._c(_DARK_GRAY, " Skill:") - skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) - skill_line = f" {skill_icon} {skill_label} {skill_path_display}" - + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) - if skill_line: - print(_box_line(skill_line)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py index c7312348a..bc1fb6d1d 100644 --- a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() # auto-detects skills/SKILL.md inside the package + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -106,27 +120,44 @@ class ReplSkin: history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history skill_path: Path to the SKILL.md file for agent discovery. - Auto-detected from the package's skills/ directory if not provided. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") - # Auto-detect skill path from package layout: - # cli_anything//utils/repl_skin.py (this file) - # cli_anything//skills/SKILL.md (target) + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. if skill_path is None: - from pathlib import Path - _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" - if _auto.is_file(): - skill_path = str(_auto) + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -156,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -164,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -178,19 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" - # Skill path for agent discovery - skill_line = None - if self.skill_path: - skill_icon = self._c(_MAGENTA, "◇") - skill_label = self._c(_DARK_GRAY, " Skill:") - skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) - skill_line = f" {skill_icon} {skill_label} {skill_path_display}" - + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) - if skill_line: - print(_box_line(skill_line)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/dify-workflow/agent-harness/cli_anything/dify_workflow/utils/repl_skin.py b/dify-workflow/agent-harness/cli_anything/dify_workflow/utils/repl_skin.py index c7312348a..bc1fb6d1d 100644 --- a/dify-workflow/agent-harness/cli_anything/dify_workflow/utils/repl_skin.py +++ b/dify-workflow/agent-harness/cli_anything/dify_workflow/utils/repl_skin.py @@ -7,7 +7,7 @@ Usage: from cli_anything..utils.repl_skin import ReplSkin skin = ReplSkin("shotcut", version="1.0.0") - skin.print_banner() # auto-detects skills/SKILL.md inside the package + skin.print_banner() # auto-detects repo-root or packaged SKILL.md prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) skin.success("Project saved") skin.error("File not found") @@ -20,6 +20,7 @@ Usage: import os import sys +from pathlib import Path # ── ANSI color codes (no external deps for core styling) ────────────── @@ -57,6 +58,8 @@ _RED = "\033[38;5;196m" _BLUE = "\033[38;5;75m" _MAGENTA = "\033[38;5;176m" +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + # ── Brand icon ──────────────────────────────────────────────────────── # The cli-anything icon: a small colored diamond/chevron mark @@ -89,6 +92,17 @@ def _visible_len(text: str) -> int: return len(_strip_ansi(text)) +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + class ReplSkin: """Unified REPL skin for cli-anything CLIs. @@ -106,27 +120,44 @@ class ReplSkin: history_file: Path for persistent command history. Defaults to ~/.cli-anything-/history skill_path: Path to the SKILL.md file for agent discovery. - Auto-detected from the package's skills/ directory if not provided. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. Displayed in banner for AI agents to know where to read skill info. """ self.software = software.lower().replace("-", "_") self.display_name = software.replace("_", " ").title() self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") - # Auto-detect skill path from package layout: - # cli_anything//utils/repl_skin.py (this file) - # cli_anything//skills/SKILL.md (target) + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. if skill_path is None: - from pathlib import Path - _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" - if _auto.is_file(): - skill_path = str(_auto) + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) self.skill_path = skill_path self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) # History file if history_file is None: - from pathlib import Path hist_dir = Path.home() / f".cli-anything-{self.software}" hist_dir.mkdir(parents=True, exist_ok=True) self.history_file = str(hist_dir / "history") @@ -156,7 +187,9 @@ class ReplSkin: def print_banner(self): """Print the startup banner with branding.""" - inner = 54 + import textwrap + + inner = 72 def _box_line(content: str) -> str: """Wrap content in box drawing, padding to inner width.""" @@ -164,6 +197,24 @@ class ReplSkin: vl = self._c(_DARK_GRAY, _V_LINE) return f"{vl}{content}{' ' * max(0, pad)}{vl}" + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") @@ -178,19 +229,14 @@ class ReplSkin: tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" empty = "" - # Skill path for agent discovery - skill_line = None - if self.skill_path: - skill_icon = self._c(_MAGENTA, "◇") - skill_label = self._c(_DARK_GRAY, " Skill:") - skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) - skill_line = f" {skill_icon} {skill_label} {skill_path_display}" - + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) print(top) print(_box_line(title)) print(_box_line(ver)) - if skill_line: - print(_box_line(skill_line)) + for line in meta_lines: + print(_box_line(line)) print(_box_line(empty)) print(_box_line(tip)) print(bot) diff --git a/docs/hub/index-modern.html b/docs/hub/index-modern.html index a3eb6afe0..4dfd4030f 100644 --- a/docs/hub/index-modern.html +++ b/docs/hub/index-modern.html @@ -258,21 +258,6 @@ flex-shrink: 0; } - .nav-link-stars { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.9rem; - padding: 0.14rem 0.52rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--text); - font-size: 0.73rem; - font-variant-numeric: tabular-nums; - line-height: 1.1; - } - .theme-toggle { width: 42px; padding: 0; @@ -1357,6 +1342,11 @@ rgba(0,0,0,0.14); } + .card-command-stack { + display: grid; + gap: 0.75rem; + } + .card--public .card-command-block { background: linear-gradient(180deg, rgba(241, 179, 107, 0.08), rgba(255,255,255,0.02)), @@ -1417,7 +1407,6 @@ } .card-links a, - .card-link-btn, .card-contributor a { display: inline-flex; align-items: center; @@ -1433,86 +1422,12 @@ } .card-links a:hover, - .card-link-btn:hover, .card-contributor a:hover { color: var(--text); border-color: var(--border-strong); background: rgba(255,255,255,0.08); } - .card-link-btn { - cursor: pointer; - font: inherit; - } - - .skill-popover { - position: relative; - display: inline-flex; - align-items: center; - } - - .skill-popover-panel { - position: absolute; - left: 0; - top: calc(100% + 0.5rem); - width: min(18.5rem, calc(100vw - 3rem)); - padding: 0.78rem; - border-radius: 18px; - border: 1px solid rgba(130, 149, 180, 0.34); - background: rgba(251, 247, 239, 0.98); - color: #1a2433; - box-shadow: 0 22px 48px rgba(8, 16, 28, 0.22); - opacity: 0; - transform: translateY(-4px); - pointer-events: none; - transition: opacity 0.18s ease, transform 0.18s ease; - z-index: 12; - } - - .skill-popover.is-open .skill-popover-panel { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - - .skill-popover-label { - display: block; - margin-bottom: 0.5rem; - font-size: 0.66rem; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - color: #67758c; - } - - .skill-popover-panel code { - display: block; - padding: 0.62rem 0.68rem; - border-radius: 14px; - border: 1px solid rgba(130, 149, 180, 0.25); - background: rgba(226, 232, 240, 0.5); - color: #182130; - font-family: 'IBM Plex Mono', monospace; - font-size: 0.74rem; - line-height: 1.6; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - } - - .skill-popover-copy { - margin-top: 0.62rem; - background: rgba(255,255,255,0.7); - color: #314155; - border-color: rgba(130, 149, 180, 0.3); - } - - .skill-popover-copy:hover { - color: #182130; - border-color: rgba(49, 65, 85, 0.3); - background: rgba(255,255,255,0.88); - } - .community-badge { display: inline-flex; align-items: center; @@ -1575,13 +1490,15 @@ } .footer-copy a, - .footer-brand a { + .footer-brand a, + .footer-analytics-link { color: var(--text); text-decoration: none; } .footer-copy a:hover, - .footer-brand a:hover { + .footer-brand a:hover, + .footer-analytics-link:hover { text-decoration: underline; } @@ -1846,13 +1763,12 @@
OpenClaw openclaw skills install cli-anything-hub @@ -2081,7 +2002,7 @@
@@ -2123,7 +2044,7 @@ }); const REPO = 'https://github.com/HKUDS/CLI-Anything'; - const GITHUB_REPO_API = 'https://api.github.com/repos/HKUDS/CLI-Anything'; + const REPO_SKILLS_SOURCE = 'HKUDS/CLI-Anything'; const REGISTRY_URLS = [ '../../registry.json', 'https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/registry.json' @@ -2164,34 +2085,6 @@ return null; } - function formatCompactCount(value) { - return new Intl.NumberFormat('en', { - notation: 'compact', - maximumFractionDigits: value >= 10000 ? 0 : 1, - }).format(value); - } - - async function loadGitHubStars() { - try { - const resp = await fetch(GITHUB_REPO_API, { - headers: { Accept: 'application/vnd.github+json' } - }); - if (!resp.ok) return; - - const repo = await resp.json(); - const stars = repo.stargazers_count; - if (typeof stars !== 'number') return; - - document.querySelectorAll('[data-github-stars]').forEach((el) => { - el.textContent = formatCompactCount(stars); - el.setAttribute('aria-label', stars.toLocaleString('en-US') + ' GitHub stars'); - el.title = stars.toLocaleString('en-US') + ' GitHub stars'; - }); - } catch (_) { - // Keep fallback placeholder when GitHub API is unavailable. - } - } - async function loadRegistries() { try { const [harnessData, publicData, dates] = await Promise.all([ @@ -2405,38 +2298,48 @@ grid.innerHTML = (deck === 'harness' ? filtered.map(renderHarnessCard) : filtered.map(renderPublicCard)).join(''); } - function isWebUrl(value) { - return /^https?:\/\//i.test(value || ''); + function repoSkillId(skillPath) { + if (!skillPath || skillPath.startsWith('http')) return ''; + const match = skillPath.match(/^skills\/([^/]+)\/SKILL\.md$/); + return match ? match[1] : ''; } - function isRepoSkillPath(value) { - return !/\s/.test(value || '') && /(^\.{0,2}\/|\/|\.md$|\.txt$)/i.test(value || ''); + function normalizeNpxSkillsCmd(cmd) { + if (!cmd) return ''; + const trimmed = cmd.trim(); + if (!trimmed.startsWith('npx skills add ')) return ''; + return trimmed + .replace(/\s+-g\b/g, '') + .replace(/\s+-y\b/g, '') + .trim() + ' -g -y'; } - function renderSkillAction(value) { - if (!value) return ''; - if (isWebUrl(value)) { - return 'Skill'; - } - if (isRepoSkillPath(value)) { - return 'Skill'; - } - return '' + - '' + - '' + - '' + - 'Skill install' + - '' + esc(value) + '' + - '' + - '' + - ''; + function harnessSkillCmd(c) { + const skillId = repoSkillId(c.skill_md); + return skillId ? 'npx skills add ' + REPO_SKILLS_SOURCE + ' --skill ' + skillId + ' -g -y' : ''; + } + + function publicSkillCmd(c) { + return normalizeNpxSkillsCmd(c.skill_md); + } + + function renderCommandStack(steps, sourceLabel) { + return '
' + steps.map((step) => + '
' + + '
' + esc(step.label) + '' + esc(step.badge || sourceLabel) + '
' + + '
' + esc(step.cmd) + '
' + + '
' + + '
' + ).join('') + '
'; } function renderHarnessCard(c) { const requiresHtml = c.requires ? '
Requires ' + esc(c.requires) + '
' : ''; - const skillLink = renderSkillAction(c.skill_md); + const skillLink = c.skill_md + ? 'Skill' + : ''; const sourceLink = c.source_url ? 'Source' : 'Source'; @@ -2447,12 +2350,18 @@ const isTeam = contributors.length === 1 && contributors[0].name === 'CLI-Anything-Team'; const contributorHtml = contributors.length ? '
' + - contributors.map((ct) => '' + esc(ct.name) + '').join(', ') + + contributors.map((ct) => '' + esc(ct.name) + '').join('') + (isTeam ? '' : 'Community') + '
' : ''; const dateHtml = c.last_modified ? '
Updated ' + esc(c.last_modified) + '
' : ''; - const linksHtml = sourceLink + skillLink; + const linksHtml = sourceLink + (skillLink ? skillLink : ''); + const installSteps = [ + { label: 'Step 1 · Install CLI', cmd: 'cli-hub install ' + c.name, badge: 'CLI-Hub' } + ]; + const skillCmd = harnessSkillCmd(c); + if (skillCmd) installSteps.push({ label: 'Step 2 · Install Skill', cmd: skillCmd, badge: 'npx skills' }); + const installBlock = renderCommandStack(installSteps, 'CLI-Hub'); return `
@@ -2469,16 +2378,7 @@ ${dateHtml} ${requiresHtml} -
-
- Install via CLI-Hub - PyPI -
-
pip install cli-anything-hub\ncli-hub install ${esc(c.name)}
-
- -
-
+ ${installBlock} ${installBlock}
`; @@ -2560,20 +2460,6 @@ }); } - function toggleSkillPopover(trigger) { - const popover = trigger.closest('.skill-popover'); - if (!popover) return; - const shouldOpen = !popover.classList.contains('is-open'); - document.querySelectorAll('.skill-popover.is-open').forEach((node) => { - node.classList.remove('is-open'); - const btn = node.querySelector('.skill-trigger'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - }); - if (!shouldOpen) return; - popover.classList.add('is-open'); - trigger.setAttribute('aria-expanded', 'true'); - } - function esc(s) { if (!s) return ''; const d = document.createElement('div'); @@ -2581,48 +2467,6 @@ return d.innerHTML; } - function escAttr(s) { - if (!s) return ''; - return String(s) - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - } - - document.addEventListener('click', (e) => { - const copyBtn = e.target.closest('.skill-popover-copy'); - if (copyBtn) { - e.preventDefault(); - e.stopPropagation(); - copyCmd(copyBtn, copyBtn.dataset.copyValue); - return; - } - - const trigger = e.target.closest('.skill-trigger'); - if (trigger) { - e.preventDefault(); - e.stopPropagation(); - toggleSkillPopover(trigger); - return; - } - - document.querySelectorAll('.skill-popover.is-open').forEach((node) => { - node.classList.remove('is-open'); - const btn = node.querySelector('.skill-trigger'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - }); - }); - - document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - document.querySelectorAll('.skill-popover.is-open').forEach((node) => { - node.classList.remove('is-open'); - const btn = node.querySelector('.skill-trigger'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - }); - }); - document.getElementById('search-harness').addEventListener('input', (e) => { deckState.harness.query = e.target.value; renderDeck('harness'); @@ -2711,47 +2555,48 @@ const UMAMI_API = 'https://api.umami.is/v1'; const UMAMI_KEY = 'api_idAebMhzn6z0hsUQT7BSxRuCK2GUZvRY'; - const HKUDS_WEBSITE_ID = '07082d05-efd3-4f85-a7a1-b426b0e8bfaa'; - const CC_WEBSITE_ID = 'a076c661-bed1-405c-a522-813794e688b4'; + const WEBSITE_IDS = [ + '07082d05-efd3-4f85-a7a1-b426b0e8bfaa', + 'a076c661-bed1-405c-a522-813794e688b4', + ]; const headers = { Accept: 'application/json', 'x-umami-api-key': UMAMI_KEY }; async function loadVisitorStats() { try { const now = Date.now(); - let hkudsHumanVisits = 0; - let ccHumanVisits = 0; - let ccAgentVisits = 0; - const [hkudsStatsResp, ccEventsResp] = await Promise.all([ - fetch(UMAMI_API + '/websites/' + HKUDS_WEBSITE_ID + '/stats?startAt=0&endAt=' + now, { headers }), - fetch(UMAMI_API + '/websites/' + CC_WEBSITE_ID + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers }) + let totalVisits = 0; + let humanCount = 0; + const fetches = WEBSITE_IDS.flatMap((id) => [ + fetch(UMAMI_API + '/websites/' + id + '/stats?startAt=0&endAt=' + now, { headers }), + fetch(UMAMI_API + '/websites/' + id + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers }) ]); + const [statsResp1, eventsResp1, statsResp2, eventsResp2] = await Promise.all(fetches); - if (hkudsStatsResp.ok) { - const stats = await hkudsStatsResp.json(); - hkudsHumanVisits = stats.visits ?? 0; + for (const resp of [statsResp1, statsResp2]) { + if (resp.ok) { + const stats = await resp.json(); + totalVisits += stats.visits ?? 0; + } } - if (ccEventsResp.ok) { - const events = await ccEventsResp.json(); - events.forEach((e) => { - if (e.x === 'visit-human') ccHumanVisits += e.y || 0; - if (e.x === 'visit-agent') ccAgentVisits += e.y || 0; - }); + for (const resp of [eventsResp1, eventsResp2]) { + if (resp.ok) { + const events = await resp.json(); + events.forEach((e) => { + if (e.x === 'visit-human') humanCount += e.y || 0; + }); + } } - const humanCount = hkudsHumanVisits + ccHumanVisits; - const totalVisits = humanCount + ccAgentVisits; - document.getElementById('stat-total').textContent = totalVisits.toLocaleString(); document.getElementById('stat-human').textContent = humanCount.toLocaleString(); - document.getElementById('stat-agent').textContent = ccAgentVisits.toLocaleString(); + document.getElementById('stat-agent').textContent = Math.max(0, totalVisits - humanCount).toLocaleString(); } catch (_) { // Leave fallback dashes. } } loadVisitorStats(); - loadGitHubStars(); })(); diff --git a/docs/hub/index.html b/docs/hub/index.html index 040c52f9b..9d3415ed3 100644 --- a/docs/hub/index.html +++ b/docs/hub/index.html @@ -17,8 +17,6 @@ --text: #fafafa; --text-secondary: #a1a1aa; --text-tertiary: #71717a; - --hero-neutral-top: #e7ecf2; - --hero-neutral-bottom: #b8c1cd; --accent: #3b82f6; --accent-muted: #2563eb; --green: #22c55e; @@ -39,8 +37,6 @@ --text: #09090b; --text-secondary: #52525b; --text-tertiary: #71717a; - --hero-neutral-top: #7f8894; - --hero-neutral-bottom: #a8b1bc; --accent: #2563eb; --accent-muted: #1d4ed8; --green: #16a34a; @@ -107,21 +103,6 @@ .nav-link svg { width: 15px; height: 15px; flex-shrink: 0; } - .nav-link-stars { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.8rem; - padding: 0.1rem 0.45rem; - border-radius: 999px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - font-size: 0.72rem; - font-variant-numeric: tabular-nums; - line-height: 1.1; - } - /* ── Theme toggle ── */ .theme-toggle { display: inline-flex; @@ -167,14 +148,7 @@ } .hero-accent { color: var(--accent); } - .hero-dim { - color: var(--hero-neutral-bottom); - background: linear-gradient(180deg, var(--hero-neutral-top) 0%, var(--hero-neutral-bottom) 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - opacity: 0.9; - } + .hero-dim { color: var(--text-tertiary); } .hero-tagline { font-size: 1.15rem; @@ -826,6 +800,27 @@ margin-bottom: 0.75rem; } + .card-install-stack { + display: grid; + gap: 0.55rem; + margin-bottom: 0.75rem; + } + + .card-install-copy { + display: grid; + gap: 0.22rem; + flex: 1; + min-width: 0; + } + + .card-install-step { + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); + font-weight: 600; + } + .card-install--note { justify-content: flex-start; } @@ -872,93 +867,16 @@ gap: 0.6rem; font-size: 0.78rem; flex-wrap: wrap; - align-items: center; } - .card-links a, - .card-link-btn { + .card-links a { color: var(--text-tertiary); text-decoration: none; font-weight: 500; transition: color 0.15s; } - .card-link-btn { - background: none; - border: 0; - cursor: pointer; - font: inherit; - padding: 0; - } - - .card-links a:hover, - .card-link-btn:hover { color: var(--text-secondary); } - - .skill-popover { - position: relative; - display: inline-flex; - align-items: center; - } - - .skill-popover-panel { - position: absolute; - left: 0; - top: calc(100% + 0.45rem); - width: min(18rem, calc(100vw - 3rem)); - padding: 0.7rem; - border-radius: 10px; - border: 1px solid rgba(148, 163, 184, 0.36); - background: rgba(248, 250, 252, 0.98); - color: #0f172a; - box-shadow: 0 16px 32px rgba(15, 23, 42, 0.24); - opacity: 0; - transform: translateY(-4px); - pointer-events: none; - transition: opacity 0.16s ease, transform 0.16s ease; - z-index: 10; - } - - .skill-popover.is-open .skill-popover-panel { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - - .skill-popover-label { - display: block; - font-size: 0.64rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #475569; - margin-bottom: 0.45rem; - } - - .skill-popover-panel code { - display: block; - font-size: 0.75rem; - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - font-family: 'JetBrains Mono', monospace; - background: rgba(226, 232, 240, 0.7); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 8px; - padding: 0.55rem 0.6rem; - } - - .skill-popover-copy { - margin-top: 0.55rem; - color: #334155; - border-color: rgba(100, 116, 139, 0.34); - background: rgba(255, 255, 255, 0.72); - } - - .skill-popover-copy:hover { - color: #0f172a; - border-color: rgba(51, 65, 85, 0.42); - } + .card-links a:hover { color: var(--text-secondary); } .card-contributor { font-size: 0.75rem; @@ -1161,9 +1079,16 @@ gap: 0.4rem; font-size: 0.78rem; color: var(--text-tertiary); + text-decoration: none; padding: 0.35rem 0.75rem; border: 1px solid var(--border); border-radius: 999px; + transition: border-color 0.15s, color 0.15s; + } + + .footer-analytics-link:hover { + border-color: var(--text-tertiary); + color: var(--text-secondary); } .footer-analytics-link svg { opacity: 0.6; flex-shrink: 0; } @@ -1242,13 +1167,12 @@