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..7da0ea8b2 --- /dev/null +++ b/.github/scripts/sync_root_skills.py @@ -0,0 +1,79 @@ +#!/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) + parts = rel.parts + if "cli_anything" in parts: + package_index = parts.index("cli_anything") + 1 + if package_index < len(parts): + package_name = parts[package_index] + return f"cli-anything-{package_name.replace('_', '-')}" + + software_dir = 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/tests/test_update_registry_dates.py b/.github/scripts/tests/test_update_registry_dates.py new file mode 100644 index 000000000..894ee5627 --- /dev/null +++ b/.github/scripts/tests/test_update_registry_dates.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "update_registry_dates.py" +SPEC = importlib.util.spec_from_file_location("update_registry_dates", SCRIPT_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(MODULE) + + +def test_resolve_harness_path_prefers_install_subdirectory_for_qgis(): + cli = { + "name": "qgis", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=QGIS/agent-harness", + "skill_md": "QGIS/agent-harness/cli_anything/qgis/skills/SKILL.md", + } + + path = MODULE.resolve_harness_path(cli, MODULE.REPO_ROOT) + + assert path == MODULE.REPO_ROOT / "QGIS" / "agent-harness" + + +def test_resolve_harness_path_handles_underscore_directory_names(): + cli = { + "name": "unimol_tools", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unimol_tools/agent-harness", + "skill_md": "skills/cli-anything-unimol-tools/SKILL.md", + } + + path = MODULE.resolve_harness_path(cli, MODULE.REPO_ROOT) + + assert path == MODULE.REPO_ROOT / "unimol_tools" / "agent-harness" + + +def test_extract_external_source_url_from_cargo_git_install(): + cli = { + "name": "clibrowser", + "install_cmd": "cargo install --git https://github.com/allthingssecurity/clibrowser.git --tag v0.1.0 --locked", + "source_url": None, + } + + source_url = MODULE.extract_external_source_url(cli) + + assert source_url == "https://github.com/allthingssecurity/clibrowser" + + +def test_extract_npm_package_supports_scoped_package_names(): + cli = { + "npm_package": "@sentry/cli", + "install_cmd": "npm install -g @sentry/cli", + } + + assert MODULE._extract_npm_package(cli) == "@sentry/cli" diff --git a/.github/scripts/update_registry_dates.py b/.github/scripts/update_registry_dates.py index 228afce39..af1bfcb32 100644 --- a/.github/scripts/update_registry_dates.py +++ b/.github/scripts/update_registry_dates.py @@ -1,105 +1,278 @@ #!/usr/bin/env python3 -"""Update registry-dates.json with last modified dates from harness directories.""" +"""Update registry-dates.json with meaningful per-CLI update dates.""" + +from __future__ import annotations + import json import re +import shlex import subprocess +import urllib.parse import urllib.request +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from pathlib import Path -from datetime import datetime -def get_last_modified(harness_path): - """Get the most recent git commit date for files in a harness directory.""" +REPO_ROOT = Path(__file__).resolve().parents[2] +USER_AGENT = "CLI-Anything registry date updater" +GITHUB_REPO_RE = re.compile(r"https://github\.com/([^/]+/[^/#?]+?)(?:\.git)?(?:[/?#].*)?$") +GIT_URL_RE = re.compile(r"https://github\.com/[^\s#]+") +SUBDIRECTORY_RE = re.compile(r"#subdirectory=([^\s]+)") + + +def _fetch_json(url: str) -> dict | None: try: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": USER_AGENT, + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + except Exception: + return None + + +def _fetch_last_modified(url: str) -> str | None: + try: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}, method="HEAD") + with urllib.request.urlopen(req, timeout=10) as resp: + last_modified = resp.headers.get("Last-Modified") + if not last_modified: + return None + return parsedate_to_datetime(last_modified).astimezone(timezone.utc).strftime("%Y-%m-%d") + except Exception: + return None + + +def _git_log_timestamp(target_path: Path, excluded_globs: tuple[str, ...] = ()) -> int | None: + try: + relative_target = target_path.relative_to(REPO_ROOT).as_posix() + cmd = ["git", "log", "-1", "--format=%ct", "--", relative_target] + cmd.extend(f":(exclude,glob){pattern}" for pattern in excluded_globs) result = subprocess.run( - ['git', 'log', '-1', '--format=%ct', '--', str(harness_path)], + cmd, capture_output=True, text=True, - check=True + check=True, + cwd=REPO_ROOT, ) - timestamp = int(result.stdout.strip()) - return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + return int(result.stdout.strip()) except (subprocess.CalledProcessError, ValueError): return None -def get_github_repo_date(source_url): +def get_last_modified(target_path: Path) -> str | None: + """Get the most recent git commit date for CLI-specific files in a repo path.""" + relative_target = target_path.relative_to(REPO_ROOT).as_posix() + shared_file_globs = ( + f"{relative_target}/cli_anything/**/utils/repl_skin.py", + f"{relative_target}/cli_anything/**/skills/SKILL.md", + f"{relative_target}/cli_anything/**/SKILL.md", + ) + + timestamp = _git_log_timestamp(target_path, excluded_globs=shared_file_globs) + if timestamp is None: + timestamp = _git_log_timestamp(target_path) + if timestamp is None: + return None + + try: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d") + except (OverflowError, OSError, ValueError): + return None + + +def get_github_repo_date(source_url: str) -> str | None: """Get the last push date from a GitHub repo via the API.""" - # Extract owner/repo from URL like https://github.com/owner/repo - match = re.match(r'https://github\.com/([^/]+/[^/]+?)(?:\.git)?$', source_url) + match = GITHUB_REPO_RE.match(source_url) if not match: return None repo_slug = match.group(1) - api_url = f'https://api.github.com/repos/{repo_slug}' - try: - req = urllib.request.Request(api_url, headers={'Accept': 'application/vnd.github.v3+json'}) - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - pushed_at = data.get('pushed_at') # e.g. "2026-04-13T08:00:00Z" - if pushed_at: - return pushed_at[:10] - except Exception: - pass - return None + data = _fetch_json(f"https://api.github.com/repos/{repo_slug}") + if not data: + return None + pushed_at = data.get("pushed_at") + return pushed_at[:10] if pushed_at else None -def get_pypi_date(install_cmd): - """Get the last release date from PyPI for a simple pip package.""" - # Only works for plain "pip install " commands (not git+ URLs) - match = re.match(r'^pip install ([a-zA-Z0-9_-]+)$', install_cmd) - if not match: +def _extract_pypi_package(install_cmd: str) -> str | None: + if not install_cmd: return None - package = match.group(1) - api_url = f'https://pypi.org/pypi/{package}/json' try: - with urllib.request.urlopen(api_url, timeout=10) as resp: - data = json.loads(resp.read()) - # Get the upload time of the latest version - latest = data.get('info', {}).get('version') - releases = data.get('releases', {}) - if latest and releases.get(latest): - upload_time = releases[latest][0].get('upload_time') # e.g. "2026-04-10T12:00:00" - if upload_time: - return upload_time[:10] - except Exception: - pass + tokens = shlex.split(install_cmd) + except ValueError: + return None + + if not tokens: + return None + + install_index = None + if tokens[:3] == ["python3", "-m", "pip"]: + install_index = 3 + elif tokens[0] in {"pip", "pip3"}: + install_index = 1 + + if install_index is None or install_index >= len(tokens) or tokens[install_index] != "install": + return None + + for token in tokens[install_index + 1 :]: + if token.startswith("-"): + continue + if "://" in token or token.startswith("git+"): + return None + return token return None -def get_external_date(cli): - """Get the last update date for an external CLI, trying GitHub API then PyPI.""" - source_url = cli.get('source_url') +def get_pypi_date(install_cmd: str) -> str | None: + """Get the last release date from PyPI for a pip-installable package.""" + package = _extract_pypi_package(install_cmd) + if not package: + return None + data = _fetch_json(f"https://pypi.org/pypi/{package}/json") + if not data: + return None + latest = data.get("info", {}).get("version") + releases = data.get("releases", {}) + release_files = releases.get(latest or "", []) + if not release_files: + return None + upload_time = release_files[0].get("upload_time") or release_files[0].get("upload_time_iso_8601") + return upload_time[:10] if upload_time else None + + +def _extract_npm_package(cli: dict) -> str | None: + package = cli.get("npm_package") + if package: + return package + + install_cmd = cli.get("install_cmd", "") + match = re.search(r"npm install -g (\S+)", install_cmd) + return match.group(1) if match else None + + +def get_npm_date(cli: dict) -> str | None: + """Get the latest publish date from the npm registry.""" + package = _extract_npm_package(cli) + if not package: + return None + encoded = urllib.parse.quote(package, safe="") + data = _fetch_json(f"https://registry.npmjs.org/{encoded}") + if not data: + return None + latest = data.get("dist-tags", {}).get("latest") + published = data.get("time", {}).get(latest or "") + return published[:10] if published else None + + +def _extract_install_subdirectory(cli: dict) -> str | None: + install_cmd = cli.get("install_cmd") or "" + match = SUBDIRECTORY_RE.search(install_cmd) + return match.group(1) if match else None + + +def _extract_skill_subdirectory(cli: dict) -> str | None: + skill_md = cli.get("skill_md") + if not skill_md or skill_md.startswith("http"): + return None + marker = "/agent-harness/" + if marker not in skill_md: + return None + return skill_md.split(marker, 1)[0] + marker.rstrip("/") + + +def resolve_harness_path(cli: dict, repo_root: Path) -> Path | None: + """Resolve the on-disk harness path for an in-repo CLI entry.""" + for relative in (_extract_install_subdirectory(cli), _extract_skill_subdirectory(cli)): + if relative: + candidate = repo_root / relative + if candidate.exists(): + return candidate + + candidate_dirs = [] + for name in (cli.get("name"), cli.get("name", "").replace("-", "_"), cli.get("name", "").replace("_", "-")): + if name and name not in candidate_dirs: + candidate_dirs.append(name) + + for directory in candidate_dirs: + candidate = repo_root / directory / "agent-harness" + if candidate.exists(): + return candidate + return None + + +def extract_external_source_url(cli: dict) -> str | None: + """Best-effort source URL discovery for third-party CLIs.""" + source_url = cli.get("source_url") + if source_url: + return source_url + + install_cmd = cli.get("install_cmd") or "" + git_match = GIT_URL_RE.search(install_cmd) + if git_match: + return git_match.group(0).removesuffix(".git") + + for field in ("homepage", "docs_url"): + value = cli.get(field) + if value and "github.com/" in value: + return value + return None + + +def get_external_date(cli: dict) -> str | None: + """Get a useful update date for external/public CLIs.""" + source_url = extract_external_source_url(cli) if source_url: date = get_github_repo_date(source_url) if date: return date - # Fallback to PyPI - return get_pypi_date(cli.get('install_cmd', '')) + + package_manager = (cli.get("package_manager") or "").lower() + if package_manager == "npm": + date = get_npm_date(cli) + if date: + return date + + date = get_pypi_date(cli.get("install_cmd", "")) + if date: + return date + + for field in ("homepage", "docs_url"): + url = cli.get(field) + if url: + date = _fetch_last_modified(url) + if date: + return date + return None -def main(): - repo_root = Path(__file__).parent.parent.parent - registry_path = repo_root / 'registry.json' - dates_path = repo_root / 'docs' / 'hub' / 'registry-dates.json' +def get_cli_date(cli: dict, repo_root: Path) -> str | None: + harness_path = resolve_harness_path(cli, repo_root) + if harness_path: + return get_last_modified(harness_path) + return get_external_date(cli) - with open(registry_path) as f: - data = json.load(f) - dates = {} - for cli in data['clis']: - if cli.get('source_url'): - # External repo: query GitHub API / PyPI for real update date - dates[cli['name']] = get_external_date(cli) - else: - # In-repo: use the last commit in the harness directory - harness_path = repo_root / cli['name'] / 'agent-harness' - if harness_path.exists(): - dates[cli['name']] = get_last_modified(harness_path) +def _load_registry(path: Path) -> list[dict]: + with path.open(encoding="utf-8") as f: + return json.load(f)["clis"] - with open(dates_path, 'w') as f: + +def main() -> None: + dates_path = REPO_ROOT / "docs" / "hub" / "registry-dates.json" + all_clis = _load_registry(REPO_ROOT / "registry.json") + _load_registry(REPO_ROOT / "public_registry.json") + + dates = {cli["name"]: get_cli_date(cli, REPO_ROOT) for cli in all_clis} + + with dates_path.open("w", encoding="utf-8") as f: json.dump(dates, f, indent=2) print(f"Updated dates for {len(dates)} CLI entries") -if __name__ == '__main__': + +if __name__ == "__main__": 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 e39f15353..413a2eacb 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/ @@ -255,6 +258,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 1e3dae366..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//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). @@ -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 3d19bd474..0c028cba9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software Thanks to all invaluable efforts from the community! More updates continuously on the way everyday.. +- **2026-04-18** 🧩 **All SKILL.md files are now being unified under the top-level `skills/` directory** — every CLI skill can be installed from one canonical source with `npx skills add HKUDS/CLI-Anything --skill -g -y`. We also added root-skill validation CI, synced contribution / PR docs and REPL skill-path hints to the new layout, and refreshed the **CLI-Hub** install-first frontend around the new `npx skills` flow. + +- **2026-04-17** 🌐 **CLI-Hub** received another install UX pass — public registry metadata and skill coverage were tightened, visit counting was corrected, and the web hub was further refined. 🧪 **Shotcut** render output duration was fixed (#92). 📝 **SKILL** contribution paths were corrected for the new docs flow (#224), and the skill generator now safely handles empty intros (#203). + +- **2026-04-16** 🗺️ **QGIS CLI** merged (#207) — a full GIS / map authoring harness landed. 🧬 **UniMol Tools CLI** merged (#219) for molecular modeling workflows. 🌐 **CLI-Hub** also added more public CLIs, including **py4csr**, refreshed its generated meta-skill, corrected SKILL contribution docs, and fixed `apt-get` package extraction in skill generation (#204). + - **2026-04-15** 🌐 **CLI-Hub** updated to **v0.2.0** — the PyPI package now supports public CLIs from multiple install sources (`pip`, `npm`, `brew`, bundled/system tools), backed by a new `public_registry.json`. The Hub frontend was redesigned with separate **CLI-Anything CLIs** and **Public CLIs** decks, and live end-to-end checks now cover real install, update, and uninstall flows across both pip and npm packages. - **2026-04-14** 🧭 **Safari CLI** merged (#212) and added to the Hub registry — browser automation via `safari-mcp`. 🎬 **Kdenlive** also received compatibility fixes for Gen 5 project output and invalid project generation. @@ -307,6 +313,8 @@ cp CLI-Anything/opencode-commands/*.md .opencode/commands/ cp CLI-Anything/cli-anything-plugin/HARNESS.md .opencode/commands/ ``` +> **Note:** Please upgrade to the latest OpenCode. Older versions use `command/` (singular) instead of `commands/`. If `commands/` does not exist, use `command/` for both global and project-level installs. + > **Note:** `HARNESS.md` is the methodology spec that all commands reference. It must be in the same directory as the commands. This adds 5 slash commands: `/cli-anything`, `/cli-anything-refine`, `/cli-anything-test`, `/cli-anything-validate`, and `/cli-anything-list`. @@ -500,7 +508,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. --- @@ -536,7 +544,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. --- @@ -670,7 +678,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 @@ -678,7 +686,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 e4477d28e..80aeb53cf 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -22,6 +22,14 @@ 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 = software_name + if harness_path.name == "agent-harness" and harness_path.parent.name: + software_dir = harness_path.parent.name + return f"cli-anything-{software_dir.replace('_', '-')}" + + @dataclass class CommandInfo: """Information about a CLI command.""" @@ -114,8 +122,13 @@ 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}" - skill_description = f"Command-line interface for {_format_display_name(software_name)} - {skill_intro[:100]}..." + skill_name = _canonical_skill_name(harness_path, 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)}" return SkillMetadata( skill_name=skill_name, @@ -467,7 +480,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: @@ -480,10 +494,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) @@ -492,6 +507,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) @@ -509,7 +527,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-anything-plugin/tests/test_skill_generator.py b/cli-anything-plugin/tests/test_skill_generator.py index e43475fd7..c2f7216d5 100644 --- a/cli-anything-plugin/tests/test_skill_generator.py +++ b/cli-anything-plugin/tests/test_skill_generator.py @@ -356,6 +356,9 @@ class TestEdgeCases: assert metadata.skill_intro == "" # No README → empty intro assert metadata.version == "1.0.0" assert metadata.command_groups == [] + # skill_description must not contain trailing " - ..." when intro is empty + assert " - " not in metadata.skill_description + assert not metadata.skill_description.endswith("...") def test_harness_with_system_package(self, tmp_path): """README with apt install instructions should extract system_package.""" diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index 71006c20e..f18b113a3 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -40,7 +40,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", @@ -152,7 +152,6 @@ class TestRegistry: cats = list_categories() assert cats == ["3d", "audio", "image"] - # ─── Installer tests ────────────────────────────────────────────────── @@ -485,7 +484,6 @@ class TestScriptStrategy: assert data["jimeng"]["strategy"] == "command" assert data["jimeng"]["package_manager"] == "script" - # ─── Analytics tests ────────────────────────────────────────────────── @@ -686,6 +684,7 @@ class TestCLI: result = self.runner.invoke(main, ["info", "gimp"]) assert "GIMP" in result.output assert "image" in result.output + assert "Install: cli-hub install gimp" 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 0ba4b2a98..74a38dc49 100644 --- a/docs/hub/index-modern.html +++ b/docs/hub/index-modern.html @@ -258,6 +258,21 @@ 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; @@ -1334,12 +1349,13 @@ } .card-command-block { - padding: 0.9rem; + padding: 0.9rem 0.9rem 0.9rem 0.9rem; border-radius: 18px; border: 1px solid var(--border); background: linear-gradient(180deg, rgba(93, 168, 255, 0.07), rgba(255,255,255,0.02)), rgba(0,0,0,0.14); + position: relative; } .card--public .card-command-block { @@ -1365,15 +1381,32 @@ font-size: 0.8rem; color: var(--text); white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - line-height: 1.6; + overflow-wrap: break-word; + word-break: normal; + line-height: 1.45; + padding-top: 0.1rem; + } + + .card-command-comment { + display: block; + color: var(--text-tertiary); + font-style: italic; + margin-bottom: 0.24rem; + padding-right: 5.2rem; + } + + .card-command-line { + display: block; + } + + .card-command-line + .card-command-comment { + margin-top: 0.42rem; } .card-command-actions { - display: flex; - justify-content: flex-end; - margin-top: 0.7rem; + position: absolute; + top: 0.96rem; + right: 0.9rem; } .card-install-note { @@ -1485,15 +1518,13 @@ } .footer-copy a, - .footer-brand a, - .footer-analytics-link { + .footer-brand a { color: var(--text); text-decoration: none; } .footer-copy a:hover, - .footer-brand a:hover, - .footer-analytics-link:hover { + .footer-brand a:hover { text-decoration: underline; } @@ -1758,12 +1789,13 @@
OpenClaw openclaw skills install cli-anything-hub @@ -1948,13 +1985,17 @@ Sort mode
@@ -2034,6 +2075,8 @@ }); 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' @@ -2059,7 +2102,7 @@ const deckState = { harness: { filter: 'all', sort: 'updated', query: '' }, - public: { filter: 'all', sort: 'name', query: '' }, + public: { filter: 'all', sort: 'updated', query: '' }, }; async function fetchJson(urls) { @@ -2074,6 +2117,34 @@ 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([ @@ -2089,6 +2160,7 @@ if (publicData && publicData.clis) { publicClis = publicData.clis; + if (dates) publicClis.forEach((c) => { c.last_modified = dates[c.name]; }); } const totalClis = harnessClis.length + publicClis.length; @@ -2287,6 +2359,45 @@ grid.innerHTML = (deck === 'harness' ? filtered.map(renderHarnessCard) : filtered.map(renderPublicCard)).join(''); } + function repoSkillId(skillPath) { + if (!skillPath || skillPath.startsWith('http')) return ''; + const match = skillPath.match(/^skills\/([^/]+)\/SKILL\.md$/); + return match ? match[1] : ''; + } + + 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 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) { + const display = steps.map((step) => + '# ' + esc(step.label.toLowerCase()) + '' + esc(step.cmd) + '' + ).join(''); + const copyText = steps.map((step) => + '# ' + step.label.toLowerCase() + '\n' + step.cmd + ).join('\n'); + return '
' + + '
Install Commands' + esc(sourceLabel) + '
' + + '
' + display + '
' + + '
' + + '
'; + } + function renderHarnessCard(c) { const requiresHtml = c.requires ? '
Requires ' + esc(c.requires) + '
' @@ -2310,6 +2421,12 @@ : ''; const dateHtml = c.last_modified ? '
Updated ' + esc(c.last_modified) + '
' : ''; 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 `
@@ -2326,16 +2443,7 @@ ${dateHtml} ${requiresHtml} -
-
- Install via CLI-Hub - PyPI -
-
pip install cli-anything-hub\ncli-hub install ${esc(c.name)}
-
- -
-
+ ${installBlock}